diff options
| author | Dan Engelbrecht <[email protected]> | 2025-04-23 17:16:16 +0200 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2025-04-23 17:16:16 +0200 |
| commit | 2ec01ef9c6e07b77afa33fe3db6335f231c78006 (patch) | |
| tree | e2224d7859c4ce88defc046f9e55b13a3f9fa41f | |
| parent | parse system dir for builds (#365) (diff) | |
| download | zen-2ec01ef9c6e07b77afa33fe3db6335f231c78006.tar.xz zen-2ec01ef9c6e07b77afa33fe3db6335f231c78006.zip | |
zen wipe command (#366)
- Feature: New `zen wipe` command for fast cleaning of directories, it will not remove the directory itself, only the content
- `--directory` - path to directory to wipe, if the directory does not exist or is empty, no action will be taken
- `--keep-readonly` - skip removal of read-only files found in directory, defaults to `true`, set to `false` to remove read-only files
- `--quiet` - reduce output to console, defaults to `false`
- `--dryrun` - simulate the wipe without removing anything, defaults to `false`
- `--yes` - skips prompt to confirm wipe of directory
- `--plain-progress` - show progress using plain output
- `--verbose` - enable verbose console output
- `--boost-workers` - increase the number of worker threads, may cause computer to be less responsive, defaults to `false`
| -rw-r--r-- | CHANGELOG.md | 9 | ||||
| -rw-r--r-- | src/zen/cmds/builds_cmd.cpp | 2 | ||||
| -rw-r--r-- | src/zen/cmds/wipe_cmd.cpp | 575 | ||||
| -rw-r--r-- | src/zen/cmds/wipe_cmd.h | 36 | ||||
| -rw-r--r-- | src/zen/zen.cpp | 3 |
5 files changed, 624 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c434412..f4dca4c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ - Bugfix: Use proper FindClose call when using fallback when getting file attributes on windows - Bugfix: Fixed race condition at final chunks when downloading multipart blobs which could lead to corruption and/or crash - Bugfix: Fixed BigInt conversion error affecting the tree view in the web UI +- Feature: New `zen wipe` command for fast cleaning of directories, it will not remove the directory itself, only the content + - `--directory` - path to directory to wipe, if the directory does not exist or is empty, no action will be taken + - `--keep-readonly` - skip removal of read-only files found in directory, defaults to `true`, set to `false` to remove read-only files + - `--quiet` - reduce output to console, defaults to `false` + - `--dryrun` - simulate the wipe without removing anything, defaults to `false` + - `--yes` - skips prompt to confirm wipe of directory + - `--plain-progress` - show progress using plain output + - `--verbose` - enable verbose console output + - `--boost-workers` - increase the number of worker threads, may cause computer to be less responsive, defaults to `false` - Feature: **EXPERIMENTAL** New `--plugins-config` option to load plugins based on transport-sdk. - It accepts a json of format `[{"name": "%path_to_dll%", "%opt1%": "%val1%", ...}, ]`. diff --git a/src/zen/cmds/builds_cmd.cpp b/src/zen/cmds/builds_cmd.cpp index b113ce6d1..6c97645cd 100644 --- a/src/zen/cmds/builds_cmd.cpp +++ b/src/zen/cmds/builds_cmd.cpp @@ -8873,7 +8873,7 @@ BuildsCommand::BuildsCommand() Ops.add_option("", "", "boost-workers", - "Increase the number of worker threads - may cause computer to less responsive", + "Increase the number of worker threads - may cause computer to be less responsive", cxxopts::value(m_BoostWorkerThreads), "<boostworkers>"); }; diff --git a/src/zen/cmds/wipe_cmd.cpp b/src/zen/cmds/wipe_cmd.cpp new file mode 100644 index 000000000..2b4e9ab3c --- /dev/null +++ b/src/zen/cmds/wipe_cmd.cpp @@ -0,0 +1,575 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "wipe_cmd.h" + +#include <zencore/filesystem.h> +#include <zencore/fmtutils.h> +#include <zencore/logging.h> +#include <zencore/string.h> +#include <zencore/timer.h> +#include <zencore/trace.h> +#include <zenutil/parallellwork.h> +#include <zenutil/workerpools.h> + +#include <signal.h> + +#include <iostream> + +ZEN_THIRD_PARTY_INCLUDES_START +#include <tsl/robin_map.h> +#include <tsl/robin_set.h> +ZEN_THIRD_PARTY_INCLUDES_END + +#if ZEN_PLATFORM_WINDOWS +# include <zencore/windows.h> +#else +# include <fcntl.h> +# include <sys/file.h> +# include <sys/stat.h> +# include <unistd.h> +#endif + +namespace zen { + +namespace { + static std::atomic<bool> AbortFlag = false; + static bool IsVerbose = false; + static bool Quiet = false; + static bool UsePlainProgress = false; + const bool SingleThreaded = false; + bool BoostWorkerThreads = true; + + WorkerThreadPool& GetIOWorkerPool() + { + return SingleThreaded ? GetSyncWorkerPool() + : BoostWorkerThreads ? GetLargeWorkerPool(EWorkloadType::Burst) + : GetMediumWorkerPool(EWorkloadType::Burst); + } + +#define ZEN_CONSOLE_VERBOSE(fmtstr, ...) \ + if (IsVerbose) \ + { \ + ZEN_CONSOLE_LOG(zen::logging::level::Info, fmtstr, ##__VA_ARGS__); \ + } + + static void SignalCallbackHandler(int SigNum) + { + if (SigNum == SIGINT) + { + AbortFlag = true; + } +#if ZEN_PLATFORM_WINDOWS + if (SigNum == SIGBREAK) + { + AbortFlag = true; + } +#endif // ZEN_PLATFORM_WINDOWS + } + + bool IsReadOnly(uint32_t Attributes) + { +#if ZEN_PLATFORM_WINDOWS + return IsFileAttributeReadOnly(Attributes); +#else + return IsFileModeReadOnly(Attributes); +#endif + } + + bool IsFileWithRetry(const std::filesystem::path& Path) + { + std::error_code Ec; + bool Result = IsFile(Path, Ec); + for (size_t Retries = 0; Ec && Retries < 3; Retries++) + { + Sleep(100 + int(Retries * 50)); + Ec.clear(); + Result = IsFile(Path, Ec); + } + if (Ec) + { + zen::ThrowSystemError(Ec.value(), Ec.message()); + } + return Result; + } + + bool SetFileReadOnlyWithRetry(const std::filesystem::path& Path, bool ReadOnly) + { + std::error_code Ec; + bool Result = SetFileReadOnly(Path, ReadOnly, Ec); + for (size_t Retries = 0; Ec && Retries < 3; Retries++) + { + Sleep(100 + int(Retries * 50)); + if (!IsFileWithRetry(Path)) + { + return false; + } + Ec.clear(); + Result = SetFileReadOnly(Path, ReadOnly, Ec); + } + if (Ec) + { + zen::ThrowSystemError(Ec.value(), Ec.message()); + } + return Result; + } + + void RemoveFileWithRetry(const std::filesystem::path& Path) + { + std::error_code Ec; + RemoveFile(Path, Ec); + for (size_t Retries = 0; Ec && Retries < 3; Retries++) + { + Sleep(100 + int(Retries * 50)); + if (!IsFileWithRetry(Path)) + { + return; + } + Ec.clear(); + RemoveFile(Path, Ec); + } + if (Ec) + { + zen::ThrowSystemError(Ec.value(), Ec.message()); + } + } + + void RemoveDirWithRetry(const std::filesystem::path& Path) + { + std::error_code Ec; + RemoveDir(Path, Ec); + for (size_t Retries = 0; Ec && Retries < 3; Retries++) + { + Sleep(100 + int(Retries * 50)); + if (!IsDir(Path)) + { + return; + } + Ec.clear(); + RemoveDir(Path, Ec); + } + if (Ec) + { + zen::ThrowSystemError(Ec.value(), Ec.message()); + } + } + + bool CleanDirectory(const std::filesystem::path& Path, + std::span<const std::string_view> ExcludeDirectories, + bool RemoveReadonly, + bool Dryrun) + { + ZEN_TRACE_CPU("CleanDirectory"); + Stopwatch Timer; + + ProgressBar Progress(UsePlainProgress); + + std::atomic<bool> CleanWipe = true; + std::atomic<uint64_t> DiscoveredItemCount = 0; + std::atomic<uint64_t> DeletedItemCount = 0; + std::atomic<uint64_t> DeletedByteCount = 0; + std::atomic<uint64_t> FailedDeleteCount = 0; + + std::vector<std::filesystem::path> SubdirectoriesToDelete; + tsl::robin_map<IoHash, size_t, IoHash::Hasher> SubdirectoriesToDeleteLookup; + tsl::robin_set<IoHash, IoHash::Hasher> SubdirectoriesToKeep; + RwLock SubdirectoriesLock; + + auto AddFoundDirectory = [&](std::filesystem::path Directory, bool Keep) -> bool { + bool Added = false; + if (Keep) + { + bool IsLeaf = true; + while (Directory != Path) + { + const std::string DirectoryString = Directory.generic_string(); + IoHash DirectoryNameHash = IoHash::HashBuffer(DirectoryString.data(), DirectoryString.length()); + RwLock::ExclusiveLockScope _(SubdirectoriesLock); + if (auto It = SubdirectoriesToKeep.find(DirectoryNameHash); It == SubdirectoriesToKeep.end()) + { + SubdirectoriesToKeep.insert(DirectoryNameHash); + if (IsLeaf) + { + Added = true; + } + } + else + { + break; + } + Directory = Directory.parent_path(); + IsLeaf = false; + } + } + else + { + bool IsLeaf = true; + while (Directory != Path) + { + const std::string DirectoryString = Directory.generic_string(); + IoHash DirectoryNameHash = IoHash::HashBuffer(DirectoryString.data(), DirectoryString.length()); + RwLock::ExclusiveLockScope _(SubdirectoriesLock); + if (SubdirectoriesToKeep.contains(DirectoryNameHash)) + { + break; + } + if (auto It = SubdirectoriesToDeleteLookup.find(DirectoryNameHash); It == SubdirectoriesToDeleteLookup.end()) + { + SubdirectoriesToDeleteLookup.insert({DirectoryNameHash, SubdirectoriesToDelete.size()}); + SubdirectoriesToDelete.push_back(Directory); + if (IsLeaf) + { + Added = true; + } + } + else + { + break; + } + Directory = Directory.parent_path(); + IsLeaf = false; + } + } + return Added; + }; + + ParallellWork Work(AbortFlag); + + struct AsyncVisitor : public GetDirectoryContentVisitor + { + AsyncVisitor(const std::filesystem::path& InPath, + std::atomic<bool>& InCleanWipe, + std::atomic<uint64_t>& InDiscoveredItemCount, + std::atomic<uint64_t>& InDeletedItemCount, + std::atomic<uint64_t>& InDeletedByteCount, + std::atomic<uint64_t>& InFailedDeleteCount, + std::span<const std::string_view> InExcludeDirectories, + bool InRemoveReadonly, + bool InDryrun, + const std::function<bool(std::filesystem::path, bool)>& InAddFoundDirectoryFunc) + : Path(InPath) + , CleanWipe(InCleanWipe) + , DiscoveredItemCount(InDiscoveredItemCount) + , DeletedItemCount(InDeletedItemCount) + , DeletedByteCount(InDeletedByteCount) + , FailedDeleteCount(InFailedDeleteCount) + , ExcludeDirectories(InExcludeDirectories) + , RemoveReadonly(InRemoveReadonly) + , Dryrun(InDryrun) + , AddFoundDirectoryFunc(InAddFoundDirectoryFunc) + { + } + virtual void AsyncVisitDirectory(const std::filesystem::path& RelativeRoot, DirectoryContent&& Content) override + { + ZEN_TRACE_CPU("CleanDirectory_AsyncVisitDirectory"); + if (!AbortFlag) + { + if (!RelativeRoot.empty()) + { + DiscoveredItemCount++; + } + if (!Content.FileNames.empty()) + { + DiscoveredItemCount += Content.FileNames.size(); + + const std::string RelativeRootString = RelativeRoot.generic_string(); + bool RemoveContent = true; + for (const std::string_view ExcludeDirectory : ExcludeDirectories) + { + if (RelativeRootString.starts_with(ExcludeDirectory)) + { + if (RelativeRootString.length() > ExcludeDirectory.length()) + { + const char MaybePathDelimiter = RelativeRootString[ExcludeDirectory.length()]; + if (MaybePathDelimiter == '/' || MaybePathDelimiter == '\\' || + MaybePathDelimiter == std::filesystem::path::preferred_separator) + { + RemoveContent = false; + break; + } + } + else + { + RemoveContent = false; + break; + } + } + } + + const std::filesystem::path ParentPath = Path / RelativeRoot; + bool KeepDirectory = RelativeRoot.empty(); + + if (RemoveContent) + { + ZEN_TRACE_CPU("DeleteFiles"); + uint64_t RemovedCount = 0; + for (size_t FileIndex = 0; FileIndex < Content.FileNames.size(); FileIndex++) + { + const std::filesystem::path& FileName = Content.FileNames[FileIndex]; + const std::filesystem::path FilePath = (ParentPath / FileName).make_preferred(); + try + { + const uint32_t Attributes = Content.FileAttributes[FileIndex]; + const bool IsReadonly = IsReadOnly(Attributes); + bool RemoveFile = false; + if (IsReadonly) + { + if (RemoveReadonly) + { + if (!Dryrun) + { + SetFileReadOnlyWithRetry(FilePath, false); + } + RemoveFile = true; + } + } + else + { + RemoveFile = true; + } + + if (RemoveFile) + { + if (!Dryrun) + { + RemoveFileWithRetry(FilePath); + } + DeletedItemCount++; + DeletedByteCount += Content.FileSizes[FileIndex]; + RemovedCount++; + ZEN_CONSOLE_VERBOSE("Removed file {}", FilePath); + } + else + { + ZEN_CONSOLE_VERBOSE("Skipped readonly file {}", FilePath); + KeepDirectory = true; + } + } + catch (const std::exception& Ex) + { + ZEN_WARN("Failed removing file {}. Reason: {}", FilePath, Ex.what()); + FailedDeleteCount++; + CleanWipe = false; + KeepDirectory = true; + } + } + ZEN_CONSOLE_VERBOSE("Removed {} files in {}", RemovedCount, ParentPath); + } + else + { + ZEN_CONSOLE_VERBOSE("Skipped removal of {} files in {}", Content.FileNames.size(), ParentPath); + } + bool Added = AddFoundDirectoryFunc(ParentPath, KeepDirectory); + if (Added) + { + ZEN_CONSOLE_VERBOSE("{} directory {}", KeepDirectory ? "Keeping" : "Removing", ParentPath); + } + } + } + } + const std::filesystem::path& Path; + std::atomic<bool>& CleanWipe; + std::atomic<uint64_t>& DiscoveredItemCount; + std::atomic<uint64_t>& DeletedItemCount; + std::atomic<uint64_t>& DeletedByteCount; + std::atomic<uint64_t>& FailedDeleteCount; + std::span<const std::string_view> ExcludeDirectories; + const bool RemoveReadonly; + const bool Dryrun; + std::function<bool(std::filesystem::path, bool)> AddFoundDirectoryFunc; + } Visitor(Path, + CleanWipe, + DiscoveredItemCount, + DeletedItemCount, + DeletedByteCount, + FailedDeleteCount, + ExcludeDirectories, + RemoveReadonly, + Dryrun, + AddFoundDirectory); + + uint64_t LastUpdateTimeMs = Timer.GetElapsedTimeMs(); + + GetDirectoryContent(Path, + DirectoryContentFlags::IncludeFiles | DirectoryContentFlags::Recursive | + DirectoryContentFlags::IncludeFileSizes | DirectoryContentFlags::IncludeAttributes, + Visitor, + GetIOWorkerPool(), + Work.PendingWork()); + + Work.Wait(UsePlainProgress ? 5000 : 200, [&](bool IsAborted, ptrdiff_t PendingWork) { + if (Quiet) + { + return; + } + ZEN_UNUSED(IsAborted, PendingWork); + LastUpdateTimeMs = Timer.GetElapsedTimeMs(); + + uint64_t Deleted = DeletedItemCount.load(); + uint64_t DeletedBytes = DeletedByteCount.load(); + uint64_t Discovered = DiscoveredItemCount.load(); + Progress.UpdateState({.Task = "Removing files ", + .Details = fmt::format("Found {}, Deleted {} ({})", Discovered, Deleted, NiceBytes(DeletedBytes)), + .TotalCount = Discovered, + .RemainingCount = Discovered - Deleted}, + false); + }); + + std::vector<std::filesystem::path> DirectoriesToDelete; + DirectoriesToDelete.reserve(SubdirectoriesToDelete.size()); + for (auto It : SubdirectoriesToDeleteLookup) + { + const IoHash& DirHash = It.first; + if (auto KeepIt = SubdirectoriesToKeep.find(DirHash); KeepIt == SubdirectoriesToKeep.end()) + { + DirectoriesToDelete.emplace_back(std::move(SubdirectoriesToDelete[It.second])); + } + } + + std::sort(DirectoriesToDelete.begin(), + DirectoriesToDelete.end(), + [](const std::filesystem::path& Lhs, const std::filesystem::path& Rhs) { + return Lhs.string().length() > Rhs.string().length(); + }); + + for (size_t SubDirectoryIndex = 0; SubDirectoryIndex < DirectoriesToDelete.size(); SubDirectoryIndex++) + { + ZEN_TRACE_CPU("DeleteDirs"); + const std::filesystem::path& DirectoryToDelete = DirectoriesToDelete[SubDirectoryIndex]; + try + { + if (!Dryrun) + { + RemoveDirWithRetry(DirectoryToDelete); + } + ZEN_CONSOLE_VERBOSE("Removed directory {}", DirectoryToDelete); + DeletedItemCount++; + } + catch (const std::exception& Ex) + { + if (!Quiet) + { + ZEN_WARN("Failed removing directory {}. Reason: {}", DirectoryToDelete, Ex.what()); + } + CleanWipe = false; + FailedDeleteCount++; + } + + uint64_t NowMs = Timer.GetElapsedTimeMs(); + if ((NowMs - LastUpdateTimeMs) >= (UsePlainProgress ? 5000 : 200)) + { + LastUpdateTimeMs = NowMs; + + uint64_t Deleted = DeletedItemCount.load(); + uint64_t DeletedBytes = DeletedByteCount.load(); + uint64_t Discovered = DiscoveredItemCount.load(); + Progress.UpdateState({.Task = "Removing folders", + .Details = fmt::format("Found {}, Deleted {} ({})", Discovered, Deleted, NiceBytes(DeletedBytes)), + .TotalCount = DirectoriesToDelete.size(), + .RemainingCount = DirectoriesToDelete.size() - SubDirectoryIndex}, + false); + } + } + + Progress.Finish(); + + uint64_t ElapsedTimeMs = Timer.GetElapsedTimeMs(); + if (!Quiet) + { + ZEN_CONSOLE("Wiped folder '{}' {} ({}) ({} failed) in {}", + Path, + DeletedItemCount.load(), + NiceBytes(DeletedByteCount.load()), + FailedDeleteCount.load(), + NiceTimeSpanMs(ElapsedTimeMs)); + } + if (FailedDeleteCount.load() > 0) + { + throw std::runtime_error(fmt::format("Failed to delete {} files/directories in '{}'", FailedDeleteCount.load(), Path)); + } + return CleanWipe; + } +} // namespace + +WipeCommand::WipeCommand() +{ + m_Options.add_options()("h,help", "Print help"); + m_Options.add_option("", "d", "directory", "Directory to wipe", cxxopts::value(m_Directory), "<directory>"); + m_Options.add_option("", "r", "keep-readonly", "Leave read-only files", cxxopts::value(m_KeepReadOnlyFiles), "<keepreadonly>"); + m_Options.add_option("", "q", "quiet", "Reduce output to console", cxxopts::value(m_Quiet), "<quiet>"); + m_Options.add_option("", "y", "yes", "Don't query for confirmation", cxxopts::value(m_Yes), "<yes>"); + m_Options.add_option("", "", "dryrun", "Do a dry run without deleting anything", cxxopts::value(m_Dryrun), "<dryrun>"); + m_Options.add_option("output", "", "plain-progress", "Show progress using plain output", cxxopts::value(m_PlainProgress), "<progress>"); + m_Options.add_option("output", "", "verbose", "Enable verbose console output", cxxopts::value(m_Verbose), "<verbose>"); + m_Options.add_option("", + "", + "boost-workers", + "Increase the number of worker threads - may cause computer to be less responsive", + cxxopts::value(m_BoostWorkerThreads), + "<boostworkers>"); + + m_Options.parse_positional({"directory"}); +} + +WipeCommand::~WipeCommand() = default; + +int +WipeCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) +{ + ZEN_UNUSED(GlobalOptions); + + signal(SIGINT, SignalCallbackHandler); +#if ZEN_PLATFORM_WINDOWS + signal(SIGBREAK, SignalCallbackHandler); +#endif // ZEN_PLATFORM_WINDOWS + + if (!ZenCmdBase::ParseOptions(argc, argv)) + { + return 0; + } + + Quiet = m_Quiet; + IsVerbose = m_Verbose; + UsePlainProgress = IsVerbose || m_PlainProgress; + BoostWorkerThreads = m_BoostWorkerThreads; + + MakeSafeAbsolutePathÍnPlace(m_Directory); + + if (!IsDir(m_Directory)) + { + return 0; + } + + while (!m_Yes) + { + const std::string Prompt = fmt::format("Do you want to wipe directory '{}'? (yes/no) ", m_Directory); + printf("%s", Prompt.c_str()); + std::string Reponse; + std::getline(std::cin, Reponse); + Reponse = ToLower(Reponse); + if (Reponse == "y" || Reponse == "yes") + { + m_Yes = true; + } + else if (Reponse == "n" || Reponse == "no") + { + return 0; + } + } + + try + { + CleanDirectory(m_Directory, {}, !m_KeepReadOnlyFiles, m_Dryrun); + } + catch (std::exception& Ex) + { + if (!m_Quiet) + { + ZEN_ERROR("{}", Ex.what()); + } + return 3; + } + + return 0; +} + +} // namespace zen diff --git a/src/zen/cmds/wipe_cmd.h b/src/zen/cmds/wipe_cmd.h new file mode 100644 index 000000000..0e910bb81 --- /dev/null +++ b/src/zen/cmds/wipe_cmd.h @@ -0,0 +1,36 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "../zen.h" + +namespace zen { + +/** Wipe directories + */ +class WipeCommand : public ZenCmdBase +{ +public: + static constexpr char Name[] = "wipe"; + static constexpr char Description[] = "Wipe the contents of a directory"; + + WipeCommand(); + ~WipeCommand(); + + virtual cxxopts::Options& Options() override { return m_Options; } + virtual int Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) override; + virtual ZenCmdCategory& CommandCategory() const override { return g_UtilitiesCategory; } + +private: + cxxopts::Options m_Options{Name, Description}; + std::filesystem::path m_Directory; + bool m_KeepReadOnlyFiles = true; + bool m_Quiet = false; + bool m_Yes = false; + bool m_PlainProgress = false; + bool m_Verbose = false; + bool m_Dryrun = false; + bool m_BoostWorkerThreads = false; +}; + +} // namespace zen diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 5ce0a89ec..e442f8a4b 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -23,6 +23,7 @@ #include "cmds/up_cmd.h" #include "cmds/version_cmd.h" #include "cmds/vfs_cmd.h" +#include "cmds/wipe_cmd.h" #include "cmds/workspaces_cmd.h" #include <zencore/callstack.h> @@ -557,6 +558,7 @@ main(int argc, char** argv) UpCommand UpCmd; VersionCommand VersionCmd; VfsCommand VfsCmd; + WipeCommand WipeCmd; WorkspaceCommand WorkspaceCmd; WorkspaceShareCommand WorkspaceShareCmd; @@ -613,6 +615,7 @@ main(int argc, char** argv) {"version", &VersionCmd, "Get zen server version"}, {"vfs", &VfsCmd, "Manage virtual file system"}, {"flush", &FlushCmd, "Flush storage"}, + {WipeCommand::Name, &WipeCmd, WipeCommand::Description}, {WorkspaceCommand::Name, &WorkspaceCmd, WorkspaceCommand::Description}, {WorkspaceShareCommand::Name, &WorkspaceShareCmd, WorkspaceShareCommand::Description}, // clang-format on |