Hello everyone,
I read several posts here about this topic, but I think I did not find a suitable solution.
I have several folder pairs on different drives that I want to synchronize.
My goal is to store all old versions in a single directory
[ioddmsc]\Revisions\<date>, but from here in different subdirectories.
The current solution, to have one base directory \Revisions will copy all files into this one directory \Revisions\2026-08-10, which will make a restore next to impossible and might overwrite files of the same name.
So, for the examples below, the revisions shall be stored in from D:\MSC\Documents to [ioddmsc]\Revisions\2026-08-10\Backup\MSC\Documents (or ...<date>\MSC\Documents), from C:\eclipse-javascript\oxygen to [ioddmsc]\Revisions\2026-08-10\Backup\c-eclipse-javascript-oxygen or at least to ...<date>\eclipse-javascript-oxygen\).
The only solutions I found up till now is to have one Revisions Directory for each FolderPair, but then these Revisions would not be stored in one base directory \Revisions\2026-08-10, but I would have several directories 2026-08-10 spread among different folders.
Is there a way to achieve this?
Thanks
<Synchronize>
<Changes>
<Left Create="right" Update="right" Delete="right"/>
<Right Create="right" Update="right" Delete="right"/>
</Changes>
<DeletionPolicy>Versioning</DeletionPolicy>
<VersioningFolder Style="TimeStamp-Folder" MaxCount="5">[ioddmsc]\Revisions</VersioningFolder>
</Synchronize>
<FolderPairs>
<Pair>
<Left>d:\MSC\Documents</Left>
<Right>[ioddmsc]\Backup\MSC\Documents</Right>
</Pair>
<Pair>
<Left>d:\svn</Left>
<Right>[ioddmsc]\Backup\svn</Right>
</Pair>
<Pair>
<Left>d:\tools</Left>
<Right>[ioddmsc]\Backup\d-tools</Right>
</Pair>
<Pair>
<Left>D:\stick</Left>
<Right>[ioddmsc]\Backup\stick</Right>
</Pair>
<Pair>
<Left>C:\eclipse-javascript-oxygen</Left>
<Right>[ioddmsc]\Backup\c-eclipse-javascript-oxygen</Right>
</Pair>
<Pair>
<Left>D:\eclipse</Left>
<Right>[ioddmsc]\Backup\d-eclipse</Right>
</Pair>
<Pair>
<Left>c:\totalcmd</Left>
<Right>[ioddmsc]\Backup\c-totalcmd</Right>
</Pair>
</FolderPairs>
solution for one base directory for versioning?
- Posts: 2
- Joined: 9 Aug 2026
-
- Posts: 2995
- Joined: 22 Aug 2012
As described as option 3 in the Versioning Manual page, you can specify your Versioning location(s) using a Macro, as something like
[Versioning Location]/%Date%
and then use the Naming Convention "Replace" (or "Time stamp [File]")
If you want or need to keep track of the left-right folder pair your Versions originate from, you can use a unique imaginary pair name or ID [PairNameOrID] for each left-right pair and specify a local Versioning location per left-right pair like
[Versioning Location]/%Date%/[PairNameOrID] for that pair.
Note that only retaining the (in your setup) youngest 5 previous versions almost certainly will not work in above setup.
You will need to delete older versions manually, e.g. based on date.
[Versioning Location]/%Date%
and then use the Naming Convention "Replace" (or "Time stamp [File]")
If you want or need to keep track of the left-right folder pair your Versions originate from, you can use a unique imaginary pair name or ID [PairNameOrID] for each left-right pair and specify a local Versioning location per left-right pair like
[Versioning Location]/%Date%/[PairNameOrID] for that pair.
Note that only retaining the (in your setup) youngest 5 previous versions almost certainly will not work in above setup.
You will need to delete older versions manually, e.g. based on date.
- Posts: 2
- Joined: 9 Aug 2026
thank you very much, this works great!
in case someone is interested, I had ChatGPT write me a script which works as a replacement for the MaxCount functionality. Note that it distinguishes between "old" Revisions and "new" Revisions.
"old" ones are marked with a timestamped directory, "new" ones with just a date directory.
In my old Revisions, the base directory had not been preserved, so comparing between old and new Revisions is a bit fuzzy.
It can be called as a follow up command upon success like this
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "d:\stick\backup-ffs\Cleanup-Backups.ps1"
in case someone is interested, I had ChatGPT write me a script which works as a replacement for the MaxCount functionality. Note that it distinguishes between "old" Revisions and "new" Revisions.
"old" ones are marked with a timestamped directory, "new" ones with just a date directory.
In my old Revisions, the base directory had not been preserved, so comparing between old and new Revisions is a bit fuzzy.
It can be called as a follow up command upon success like this
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "d:\stick\backup-ffs\Cleanup-Backups.ps1"
# ============================================================
# Cleanup-Backups.ps1
#
# Usage:
#
# Dry run (default):
# powershell.exe -File .\Cleanup-Backups.ps1
#
# Actually delete:
# powershell.exe -File .\Cleanup-Backups.ps1 -Delete
#
#
# Directory formats:
#
# YYYY-MM-DD
# G:\Revisions\YYYY-MM-DD\<basedir>\<subfolder>\<file>
#
# YYYY-MM-DD HHMMSS
# G:\Revisions\YYYY-MM-DD HHMMSS\<subfolder>\<file>
#
#
# Duplicate rules:
#
# Date-only backups:
# key = <basedir>\<subfolder>\<file>
#
# Timestamped backups:
# key = <subfolder>\<file>
#
# Since old timestamped backups are missing <basedir>, a
# timestamped file can match the END of a date-only path.
#
# A timestamped file is assigned to AT MOST ONE date-only
# group. If multiple groups could match it, the alphabetically
# first matching group gets it.
#
# Timestamped files that don't match any date-only group are
# grouped by their exact relative path.
# ============================================================
param(
[switch]$Delete
)
$DryRun = -not $Delete
# ------------------------------------------------------------
# Configuration
# ------------------------------------------------------------
$BackupRoot = "G:\Revisions"
$KeepCopies = 5
# ------------------------------------------------------------
# Find revision directories
# ------------------------------------------------------------
$RevisionDirs = Get-ChildItem -LiteralPath $BackupRoot -Directory |
Where-Object {
$_.Name -match '^\d{4}-\d{2}-\d{2}( \d{6})?$'
} |
ForEach-Object {
$Date = $null
$DateOnly = $false
# YYYY-MM-DD HHMMSS
if ($_.Name -match '^\d{4}-\d{2}-\d{2} \d{6}$') {
$Date = [datetime]::ParseExact(
$_.Name,
'yyyy-MM-dd HHmmss',
[Globalization.CultureInfo]::InvariantCulture
)
}
# YYYY-MM-DD
elseif ($_.Name -match '^\d{4}-\d{2}-\d{2}$') {
$Date = [datetime]::ParseExact(
$_.Name,
'yyyy-MM-dd',
[Globalization.CultureInfo]::InvariantCulture
)
$DateOnly = $true
}
if ($null -ne $Date) {
[PSCustomObject]@{
Directory = $_
Date = $Date
DateOnly = $DateOnly
}
}
}
# ------------------------------------------------------------
# Find all files
# ------------------------------------------------------------
$AllFiles = foreach ($Revision in $RevisionDirs) {
if ($Revision.DateOnly) {
# ----------------------------------------------------
# YYYY-MM-DD
#
# The first directory below the revision directory is
# <basedir>.
# ----------------------------------------------------
$BaseDirectories = Get-ChildItem `
-LiteralPath $Revision.Directory.FullName `
-Directory
foreach ($BaseDirectory in $BaseDirectories) {
Get-ChildItem `
-LiteralPath $BaseDirectory.FullName `
-File `
-Recurse |
ForEach-Object {
# Result:
#
# <basedir>\<subfolder>\<file>
#
$RelativePath = $_.FullName.Substring(
$Revision.Directory.FullName.Length + 1
)
[PSCustomObject]@{
File = $_
RelativePath = $RelativePath
DateOnly = $true
RevisionDate = $Revision.Date
RevisionDir = $Revision.Directory.FullName
}
}
}
}
else {
# ----------------------------------------------------
# YYYY-MM-DD HHMMSS
#
# Old format: <basedir> is missing.
# ----------------------------------------------------
Get-ChildItem `
-LiteralPath $Revision.Directory.FullName `
-File `
-Recurse |
ForEach-Object {
# Result:
#
# <subfolder>\<file>
#
$RelativePath = $_.FullName.Substring(
$Revision.Directory.FullName.Length + 1
)
[PSCustomObject]@{
File = $_
RelativePath = $RelativePath
DateOnly = $false
RevisionDate = $Revision.Date
RevisionDir = $Revision.Directory.FullName
}
}
}
}
# ------------------------------------------------------------
# Separate the two types of backups
# ------------------------------------------------------------
$DateOnlyFiles = @(
$AllFiles | Where-Object { $_.DateOnly }
)
$TimestampedFiles = @(
$AllFiles | Where-Object { -not $_.DateOnly }
)
# ------------------------------------------------------------
# Group date-only files.
#
# Their complete path, including basedir, is significant.
# ------------------------------------------------------------
$DateOnlyGroups = @(
$DateOnlyFiles |
Group-Object RelativePath |
Sort-Object Name
)
# ------------------------------------------------------------
# Assign every timestamped file to AT MOST ONE date-only
# group.
#
# Example:
#
# timestamped:
# readme.md
#
# could match:
#
# project1\readme.md
# project2\readme.md
#
# It is assigned to only one of them.
#
# We use alphabetical order to make the decision deterministic.
# ------------------------------------------------------------
$AssignedTimestamped = @{}
foreach ($Timestamped in $TimestampedFiles) {
$MatchingGroups = @(
$DateOnlyGroups |
Where-Object {
$GroupPath = $_.Name
$TimestampedPath = $Timestamped.RelativePath
# Exact match
if ($GroupPath.Equals(
$TimestampedPath,
[StringComparison]::OrdinalIgnoreCase
)) {
return $true
}
# Timestamped path is the suffix of the
# date-only path.
#
# Example:
#
# project1\readme.md
# readme.md
#
return $GroupPath.EndsWith(
"\" + $TimestampedPath,
[StringComparison]::OrdinalIgnoreCase
)
}
)
if ($MatchingGroups.Count -gt 0) {
# Because DateOnlyGroups is sorted alphabetically,
# this is deterministic.
$SelectedGroup = $MatchingGroups[0]
$AssignedTimestamped[$Timestamped.File.FullName] =
$SelectedGroup.Name
}
}
# ------------------------------------------------------------
# Build the final duplicate groups.
#
# Key = logical <basedir>\<subfolder>\<file> for date-only
# files, or the selected date-only key for timestamped
# files.
# ------------------------------------------------------------
$LogicalEntries = @()
# Add date-only files
foreach ($Entry in $DateOnlyFiles) {
$LogicalEntries += [PSCustomObject]@{
File = $Entry.File
LogicalPath = $Entry.RelativePath
RevisionDate = $Entry.RevisionDate
RevisionDir = $Entry.RevisionDir
DateOnly = $true
}
}
# Add timestamped files that could be assigned to a
# date-only group.
foreach ($Entry in $TimestampedFiles) {
$FileName = $Entry.File.FullName
if ($AssignedTimestamped.ContainsKey($FileName)) {
$LogicalPath = $AssignedTimestamped[$FileName]
$LogicalEntries += [PSCustomObject]@{
File = $Entry.File
LogicalPath = $LogicalPath
RevisionDate = $Entry.RevisionDate
RevisionDir = $Entry.RevisionDir
DateOnly = $false
}
}
}
# ------------------------------------------------------------
# Process groups containing date-only files.
# ------------------------------------------------------------
$LogicalGroups = @(
$LogicalEntries |
Group-Object LogicalPath
)
$DeletedCount = 0
$WouldDeleteCount = 0
foreach ($Group in $LogicalGroups) {
if ($Group.Count -le $KeepCopies) {
continue
}
# Oldest first.
#
# RevisionDate is derived from the revision directory,
# not the filesystem's LastWriteTime.
#
$FilesToDelete = @(
$Group.Group |
Sort-Object RevisionDate |
Select-Object -First (
$Group.Count - $KeepCopies
)
)
foreach ($Entry in $FilesToDelete) {
if ($DryRun) {
Write-Host "[DRY RUN] Would delete:" `
-ForegroundColor Yellow
Write-Host " $($Entry.File.FullName)"
Write-Host " Logical path: $($Entry.LogicalPath)"
Write-Host " Revision: $($Entry.RevisionDate)"
Write-Host ""
$WouldDeleteCount++
}
else {
Write-Host "Deleting: $($Entry.File.FullName)" `
-ForegroundColor Red
Remove-Item `
-LiteralPath $Entry.File.FullName `
-Force
$DeletedCount++
}
}
}
# ------------------------------------------------------------
# Timestamped files that were NOT assigned to a date-only
# group are handled separately.
#
# They are compared only with other timestamped files having
# exactly the same relative path.
# ------------------------------------------------------------
$UnassignedTimestamped = @(
$TimestampedFiles |
Where-Object {
-not $AssignedTimestamped.ContainsKey(
$_.File.FullName
)
}
)
$TimestampedGroups = @(
$UnassignedTimestamped |
Group-Object RelativePath
)
foreach ($Group in $TimestampedGroups) {
if ($Group.Count -le $KeepCopies) {
continue
}
$FilesToDelete = @(
$Group.Group |
Sort-Object RevisionDate |
Select-Object -First (
$Group.Count - $KeepCopies
)
)
foreach ($Entry in $FilesToDelete) {
if ($DryRun) {
Write-Host "[DRY RUN] Would delete:" `
-ForegroundColor Yellow
Write-Host " $($Entry.File.FullName)"
Write-Host " Logical path: $($Group.Name)"
Write-Host " Revision: $($Entry.RevisionDate)"
Write-Host ""
$WouldDeleteCount++
}
else {
Write-Host "Deleting: $($Entry.File.FullName)" `
-ForegroundColor Red
Remove-Item `
-LiteralPath $Entry.File.FullName `
-Force
$DeletedCount++
}
}
}
# ------------------------------------------------------------
# Summary
# ------------------------------------------------------------
Write-Host ""
Write-Host "============================================"
Write-Host "Backup cleanup complete"
Write-Host ""
if ($DryRun) {
Write-Host "DRY RUN: no files were deleted." `
-ForegroundColor Yellow
Write-Host "Files that would be deleted: $WouldDeleteCount"
Write-Host ""
Write-Host "To actually delete them, run:"
Write-Host ""
Write-Host "powershell.exe -File `"$PSCommandPath`" -Delete"
}
else {
Write-Host "Files deleted: $DeletedCount" `
-ForegroundColor Green
}
Write-Host "============================================"