From 2f339c56637f7563f748aaf776fe593b430e1e26 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Tue, 14 Oct 2025 06:47:51 +1100 Subject: [PATCH] Implement FR #3435: Add option to remove source folders (#3473) * Add option to remove source folders when using --upload-only --remove-source-files * Parental path will only be removed if path is empty - that is, no other folders or files present --- .github/actions/spelling/allow.txt | 1 + docs/application-config-options.md | 17 +++++++++++ onedrive.1.in | 4 +++ src/config.d | 7 ++++- src/sync.d | 49 ++++++++++++++++++++++++++++++ src/util.d | 11 +++++++ 6 files changed, 88 insertions(+), 1 deletion(-) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index ff424619..a108f2f2 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -75,6 +75,7 @@ Cforce checkinterval chkconfig chpst +chrooted classinfo clen cloexec diff --git a/docs/application-config-options.md b/docs/application-config-options.md index 8fcf8046..5a6f56cc 100644 --- a/docs/application-config-options.md +++ b/docs/application-config-options.md @@ -734,6 +734,23 @@ _**CLI Option Use:**_ `--remove-source-files` > [!IMPORTANT] > This configuration option can *only* be used in conjunction with `--upload-only` +### remove_source_folders +_**Description:**_ This configuration option controls whether the OneDrive Client for Linux removes the local directory structure post successful file transfer to Microsoft OneDrive. + +_**Value Type:**_ Boolean + +_**Default Value:**_ False + +_**Config Example:**_ `remove_source_folders = "false"` or `remove_source_folders = "true"` + +_**CLI Option Use:**_ `--remove-source-folders` + +> [!IMPORTANT] +> This configuration option can *only* be used in conjunction with `--upload-only --remove-source-files` + +> [!IMPORTANT] +> The directory structure will only be removed if it is empty. + ### resync _**Description:**_ This configuration option controls whether the known local sync state with Microsoft OneDrive is removed at application startup. When this option is used, a full scan of your data online is performed to ensure that the local sync state is correctly built back up. diff --git a/onedrive.1.in b/onedrive.1.in index e7e71be4..b9ea6995 100644 --- a/onedrive.1.in +++ b/onedrive.1.in @@ -288,6 +288,10 @@ Remove a directory on OneDrive - no sync will be performed. \fB\-\-remove-source-files\fR Remove source file after successful transfer to OneDrive when using --upload-only. +.TP +\fB\-\-remove-source-folders\fR +Remove the local directory structure post successful file transfer to Microsoft OneDrive when using --upload-only --remove-source-files. + .TP \fB\-\-resync\fR Forget the last saved state, perform a full sync. diff --git a/src/config.d b/src/config.d index c503bf60..5411f5b9 100644 --- a/src/config.d +++ b/src/config.d @@ -361,8 +361,10 @@ class ApplicationConfig { boolValues["dry_run"] = false; // Do we sync all the files in the 'sync_dir' root? boolValues["sync_root_files"] = false; - // Do we delete source after successful transfer? + // Do we delete source file after successful transfer? boolValues["remove_source_files"] = false; + // Do we delete source folders after successful transfer? + boolValues["remove_source_folders"] = false; // Do we perform strict matching for skip_dir? boolValues["skip_dir_strict_match"] = false; // Do we perform a --resync? @@ -1308,6 +1310,9 @@ class ApplicationConfig { "remove-source-files", "Remove source file after successful transfer to OneDrive when using --upload-only", &boolValues["remove_source_files"], + "remove-source-folders", + "Remove the local directory structure post successful file transfer to Microsoft OneDrive when using --upload-only --remove-source-files", + &boolValues["remove_source_folders"], "single-directory", "Specify a single local directory within the OneDrive root to sync", &stringValues["single_directory"], diff --git a/src/sync.d b/src/sync.d index 2e4d93c0..f09b059a 100644 --- a/src/sync.d +++ b/src/sync.d @@ -6727,6 +6727,15 @@ class SyncEngine { if (debugLogging) {addLogEntry("Removing local file: " ~ localPathToRemove, ["debug"]);} safeRemove(localPathToRemove); addLogEntry("Removed local file: " ~ localPathToRemove); + + // Do we try and attempt to remove the local source tree? + if (appConfig.getValueBool("remove_source_folders")) { + // Remove the source directory structure but only if it is empty + addLogEntry("Attempting removal of local directory structure as --upload-only & --remove-source-files & --remove-source-folders configured"); + string parentPath = dirName(localPathToRemove); + removeEmptyParents(localPathToRemove); + addLogEntry("Removed parental path: " ~ parentPath); + } } else { // --dry-run scenario addLogEntry("Not removing local file as --dry-run configured"); @@ -6736,7 +6745,47 @@ class SyncEngine { addLogEntry("Removing local file not possible as local file does not exist"); } } + + // Remove empty parent directories of `filePath` upwards until: + // - we hit a non-empty directory, or + // - we reach the visible root (i.e. dirName(current) == "."). + // Never tries to remove ".". + void removeEmptyParents(string filePath) { + // Work with a normalised *relative* path inside the chrooted configured 'sync_dir' + // If someone passed an absolute path, normalise it anyway; your codebase + // likely already ensures paths are relative within the sync root. + string current = dirName(buildNormalizedPath(filePath)); + while (current.length && current != ".") { + // Safety: don’t descend into symlinks + if (isSymlink(current)) { + if (debugLogging) addLogEntry("Skipping removal; parent is a symlink: " ~ current, ["debug"]); + break; + } + + // Stop at first non-empty directory + if (!isDirEmpty(current)) { + if (debugLogging) addLogEntry("Stopping prune; directory not empty: " ~ current, ["debug"]); + break; + } + + if (!dryRun) { + if (debugLogging) addLogEntry("Removing empty directory: " ~ current, ["debug"]); + // rmdir only succeeds for empty directories; errors are collected not thrown + collectException(rmdir(current)); + } else { + addLogEntry("Not removing empty directory as --dry-run configured: " ~ current); + } + + // Move up one level + string next = dirName(current); + if (next == current) { // Just in case (shouldn’t happen with relative paths) + break; + } + current = next; + } + } + // Perform the upload of a locally modified file to OneDrive JSONValue performModifiedFileUpload(Item dbItem, string localFilePath, long thisFileSizeLocal) { // Function Start Time diff --git a/src/util.d b/src/util.d index abd2d6fc..d968f289 100644 --- a/src/util.d +++ b/src/util.d @@ -1917,3 +1917,14 @@ void displayFunctionProcessingTime(string functionName, SysTime functionStartTim string processingTime = format("[%s] Application Function '%s' Processing Time = %.4f Seconds", strip(logKey), strip(functionName), functionDurationAsSeconds); addLogEntry(processingTime); } + +// Return true if `dir` exists and has no entries. +// Symlinks are treated as non-removable. +bool isDirEmpty(string dir) { + if (!exists(dir) || !isDir(dir) || isSymlink(dir)) return false; + foreach (_; dirEntries(dir, SpanMode.shallow)) { + // Found at least one entry + return false; + } + return true; +}