Skip to content

Commit 356df80

Browse files
authored
Merge pull request stride3d#3244 from xen2/feature/upgrade-backup
Backup modified files before upgrade
2 parents 49e66f0 + 9175579 commit 356df80

17 files changed

Lines changed: 416 additions & 41 deletions

File tree

sources/assets/Stride.AssetCompiler/PackageBuilderApp.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ public int Run(string[] args)
117117
{ "pack-asset-assembly=", "Host-loadable asset assembly (package-relative path) to declare in the packed sdpkg; repeat for each", v => options.PackAssetAssemblies.Add(v) },
118118
{ "t|threads=", "Number of threads to create. Default value is the number of hardware threads available.", v => options.ThreadCount = int.Parse(v) },
119119
{ "test=", "Run a test session.", v => options.TestName = v },
120+
{ "no-backup", "Upgrade verb only: skip backing up the files the upgrade overwrites (backup is on by default).", v => options.NoBackup = v != null },
120121
{ "property:", "Properties. Format is name1=value1;name2=value2", v =>
121122
{
122123
if (!string.IsNullOrEmpty(v))
@@ -347,13 +348,22 @@ public int Run(string[] args)
347348
PackageUpgradeRequested = (pkg, upgrades) => PackageUpgradeRequestedAnswer.UpgradeAll,
348349
// Whole-solution upgrade may start mixed (e.g. a shared pack already bumped); tolerate the transient NU1605.
349350
AllowUpgradeDowngradeRestore = true,
351+
// Snapshot every file the upgrade overwrites into a timestamped backup folder unless opted out.
352+
BackupBeforeUpgrade = !options.NoBackup,
350353
};
351354

352355
var sessionResult = PackageSession.Load(upgradeTarget, loadParameters);
353356
sessionResult.CopyTo(options.Logger);
354-
if (sessionResult.HasErrors || sessionResult.Session == null)
357+
if (sessionResult.Session == null)
355358
return (int)BuildResultCode.BuildError;
356359

360+
// Be lenient like Game Studio: load errors (e.g. a project that won't build against the new
361+
// version, so its script-referencing assets load as IUnloadable) don't abort the upgrade.
362+
// Reconcile and save whatever loaded — IUnloadable round-trips its original YAML, so nothing
363+
// is lost — and let the exit code below still report the errors.
364+
if (sessionResult.HasErrors)
365+
options.Logger.Warning("The session loaded with errors; upgrading and saving the assets that loaded. Fix the errors and re-run for a complete upgrade.");
366+
357367
ReconcileBases(sessionResult.Session, options.Logger);
358368

359369
sessionResult.Session.Save(options.Logger);

sources/assets/Stride.AssetCompiler/PackageBuilderOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public class PackageBuilderOptions
1515

1616
public bool Verbose = false;
1717
public bool Debug = false;
18+
// Upgrade verb only: skip the copy-on-write backup of files the in-place upgrade overwrites (on by default).
19+
public bool NoBackup { get; set; }
1820
// This should not be a list
1921
public bool DisableAutoCompileProjects { get; set; }
2022
public string ProjectConfiguration { get; set; }

sources/assets/Stride.Core.Assets/CodeUpgrade/CodeUpgradeRunner.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ public interface ICodeUpgradeRunner
2626
{
2727
/// <param name="solutionPath">The solution to open (already restored at the old versions), or <c>null</c> for a standalone project.</param>
2828
/// <param name="pending">The projects pending source migration, with the upgrader that declared rules and the version each is upgraded from.</param>
29+
/// <param name="backup">The copy-on-write backup to snapshot each source file into before overwriting it, or <c>null</c> when no backup is requested.</param>
2930
/// <param name="log">The logger.</param>
30-
void Run(UFile? solutionPath, IReadOnlyList<PendingCodeUpgrade> pending, ILogger log);
31+
void Run(UFile? solutionPath, IReadOnlyList<PendingCodeUpgrade> pending, UpgradeBackup? backup, ILogger log);
3132
}
3233

3334
/// <summary>

sources/assets/Stride.Core.Assets/PackageLoadParameters.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ public PackageLoadParameters()
135135
/// </summary>
136136
public bool AllowUpgradeDowngradeRestore { get; set; }
137137

138+
/// <summary>
139+
/// When set, an in-place upgrade snapshots each original file into a timestamped backup folder right before
140+
/// it overwrites it (copy-on-write — only modified files are copied), so the upgrade stays recoverable.
141+
/// Front-ends opt in (the asset compiler and GameStudio default it on); off for normal loads.
142+
/// </summary>
143+
public bool BackupBeforeUpgrade { get; set; }
144+
138145
/// <summary>
139146
/// Occurs when an asset is about to be loaded, if false is returned the asset will be ignored and not loaded.
140147
/// </summary>

sources/assets/Stride.Core.Assets/PackageSession.cs

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,13 @@ public UFile SolutionPath
494494

495495
public AssemblyContainer AssemblyContainer { get; }
496496

497+
/// <summary>
498+
/// The copy-on-write backup armed for an in-place upgrade (see <see cref="PackageLoadParameters.BackupBeforeUpgrade"/>),
499+
/// or <c>null</c> when no backup is requested. The upgrade write points call <see cref="UpgradeBackup.Snapshot"/>
500+
/// on it before overwriting a file.
501+
/// </summary>
502+
public UpgradeBackup? UpgradeBackup { get; private set; }
503+
497504
/// <summary>
498505
/// The targeted visual studio version (if specified by the loaded package)
499506
/// </summary>
@@ -903,6 +910,26 @@ public void LoadMissingReferences(ILogger log, PackageLoadParameters? loadParame
903910
LoadMissingAssets(log, [.. Packages], loadParameters);
904911
}
905912

913+
// Constructs the copy-on-write upgrade backup the first time an upgrade-enabled load runs, mirroring the
914+
// solution directory (or, for a standalone project upgrade with no solution, the project's directory).
915+
private void ArmUpgradeBackup(PackageLoadParameters loadParameters, ILogger log)
916+
{
917+
if (!loadParameters.BackupBeforeUpgrade || UpgradeBackup is not null)
918+
return;
919+
920+
var root = SolutionPath is not null
921+
? Path.GetDirectoryName(SolutionPath.ToOSPath())
922+
: Projects.OfType<SolutionProject>().Select(x => Path.GetDirectoryName(x.FullPath.ToOSPath())).FirstOrDefault();
923+
if (!string.IsNullOrEmpty(root))
924+
UpgradeBackup = new UpgradeBackup(root, DateTime.Now, log);
925+
}
926+
927+
/// <summary>
928+
/// Disarms the upgrade backup so subsequent saves no longer snapshot files. <see cref="Save"/> does this
929+
/// automatically after persisting an upgrade; a front-end also calls it for a clean load where no save runs.
930+
/// </summary>
931+
public void DisarmUpgradeBackup() => UpgradeBackup = null;
932+
906933
/// <summary>
907934
/// Make sure packages have their dependencies loaded.
908935
/// </summary>
@@ -914,6 +941,10 @@ public void LoadMissingDependencies(ILogger log, PackageLoadParameters? loadPara
914941

915942
var cancelToken = loadParameters.CancelToken;
916943

944+
// Arm the copy-on-write backup once, before any upgrade write point runs. Copy-on-write keeps this
945+
// safe to arm eagerly: the backup folder is only created if an upgrade actually overwrites a file.
946+
ArmUpgradeBackup(loadParameters, log);
947+
917948
try
918949
{
919950
// Restore the whole solution once up front (one project-graph evaluation) instead of one
@@ -944,7 +975,7 @@ public void LoadMissingDependencies(ILogger log, PackageLoadParameters? loadPara
944975
{
945976
var pendingCodeUpgrades = DetectPendingCodeUpgrades(log, loadParameters);
946977
if (pendingCodeUpgrades.Count > 0)
947-
codeUpgradeRunner.Run(SolutionPath, pendingCodeUpgrades, log);
978+
codeUpgradeRunner.Run(SolutionPath, pendingCodeUpgrades, UpgradeBackup, log);
948979
}
949980
catch (NotImplementedException)
950981
{
@@ -1057,6 +1088,23 @@ public void Save(ILogger log, PackageSaveParameters? saveParameters = null)
10571088
return;
10581089
}
10591090

1091+
// Copy-on-write backup of the files this save is about to overwrite during an upgrade: the dirty
1092+
// packages and their dirty assets. No-op outside an upgrade (UpgradeBackup is null); copy-once.
1093+
if (UpgradeBackup is { } upgradeBackup)
1094+
{
1095+
foreach (var package in LocalPackages)
1096+
{
1097+
if (package.IsDirty && package.FullPath is not null)
1098+
upgradeBackup.Snapshot(package.FullPath.ToOSPath());
1099+
1100+
foreach (var assetItem in package.Assets)
1101+
{
1102+
if (assetItem.IsDirty)
1103+
upgradeBackup.Snapshot(assetItem.FullPath.ToOSPath());
1104+
}
1105+
}
1106+
}
1107+
10601108
// Suspend tracking when saving as we don't want to receive
10611109
// all notification events
10621110
dependencies?.BeginSavingSession();
@@ -1148,17 +1196,22 @@ public void Save(ILogger log, PackageSaveParameters? saveParameters = null)
11481196
dependencies?.EndSavingSession();
11491197

11501198
// Once all packages and assets have been saved, we can save the solution (as we need to have fullpath to
1151-
// be setup for the packages). Skip when the session wasn't loaded from a .sln (empty FullPath).
1199+
// be setup for the packages). Skip when the session wasn't loaded from a .sln (empty FullPath). The
1200+
// callback snapshots the .sln only when Save actually rewrites it.
11521201
if (packagesSaved && !string.IsNullOrEmpty(VSSolution.FullPath))
11531202
{
1154-
VSSolution.Save();
1203+
VSSolution.Save(path => UpgradeBackup?.Snapshot(path));
11551204
}
11561205
saveCompletion?.SetResult(0);
11571206
saveCompletion = null;
11581207
}
11591208

11601209
//System.Diagnostics.Trace.WriteLine("Elapsed saved: " + clock.ElapsedMilliseconds);
11611210
IsDirty = packagesDirty;
1211+
1212+
// The backup (armed only during an upgrade) has done its job for this save; disarm so later saves of
1213+
// this session don't snapshot. Normal saves run with it already null, so this is a no-op.
1214+
DisarmUpgradeBackup();
11621215
}
11631216

11641217
private Dictionary<UFile, AssetItem> BuildAssetsOrPackagesToRemove()
@@ -1483,6 +1536,11 @@ private bool TryLoadAssemblies(PackageSession session, ILogger log, Package pack
14831536
packageUpgradeAllowed = true;
14841537
if (upgradeAllowed == PackageUpgradeRequestedAnswer.DoNotUpgradeAny)
14851538
packageUpgradeAllowed = false;
1539+
1540+
// The confirmation callback may have opted out of the backup (e.g. the GameStudio dialog's
1541+
// checkbox); disarm so the upgrade writes that follow this point don't snapshot.
1542+
if (!loadParameters.BackupBeforeUpgrade)
1543+
DisarmUpgradeBackup();
14861544
}
14871545

14881546
if (!PackageLoadParameters.ShouldUpgrade(upgradeAllowed))
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
2+
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using Stride.Core.Diagnostics;
8+
9+
namespace Stride.Core.Assets;
10+
11+
/// <summary>
12+
/// Copy-on-write snapshot of the files an in-place upgrade is about to overwrite. Armed by
13+
/// <see cref="PackageLoadParameters.BackupBeforeUpgrade"/> and called from each upgrade write point right
14+
/// before it overwrites a file. The timestamped backup folder is created lazily on the first real snapshot
15+
/// (so nothing is written when an upgrade modifies no files), and each original is copied at most once
16+
/// (a file may be touched by several upgraders).
17+
/// </summary>
18+
public sealed class UpgradeBackup
19+
{
20+
private readonly string rootDirectory;
21+
private readonly string backupFolderName;
22+
private readonly ILogger? log;
23+
private readonly object lockObject = new();
24+
private readonly HashSet<string> snapshotted = new(StringComparer.OrdinalIgnoreCase);
25+
private string? backupDirectory;
26+
27+
/// <param name="rootDirectory">
28+
/// The folder the backup mirrors — each file is stored under it preserving its path relative to this
29+
/// root. Usually the solution directory (the project directory for a standalone upgrade).
30+
/// </param>
31+
/// <param name="timestamp">The upgrade start time, used to name the backup folder.</param>
32+
/// <param name="log">Logger that receives a single notice when the backup folder is first created.</param>
33+
public UpgradeBackup(string rootDirectory, DateTime timestamp, ILogger? log = null)
34+
{
35+
this.rootDirectory = Path.GetFullPath(rootDirectory);
36+
backupFolderName = $".stride-backup-{timestamp:yyyyMMdd-HHmmss}";
37+
this.log = log;
38+
}
39+
40+
/// <summary>
41+
/// Copies <paramref name="originalFullPath"/> into the backup folder (preserving its path relative to the
42+
/// backup root) the first time it is seen. No-op if the file doesn't exist, lies outside the root, or sits
43+
/// under <c>bin/</c>, <c>obj/</c>, or the backup folder. Safe to call repeatedly for the same file.
44+
/// </summary>
45+
public void Snapshot(string originalFullPath)
46+
{
47+
if (string.IsNullOrEmpty(originalFullPath))
48+
return;
49+
50+
var fullPath = Path.GetFullPath(originalFullPath);
51+
52+
var relative = Path.GetRelativePath(rootDirectory, fullPath);
53+
// Outside the root (relative escapes with "..") or excluded build/backup output: skip.
54+
if (relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative))
55+
return;
56+
if (relative.StartsWith(".stride-backup", StringComparison.Ordinal)
57+
|| ContainsSegment(relative, "bin")
58+
|| ContainsSegment(relative, "obj"))
59+
return;
60+
61+
lock (lockObject)
62+
{
63+
if (!snapshotted.Add(fullPath))
64+
return;
65+
66+
if (!File.Exists(fullPath))
67+
return;
68+
69+
if (backupDirectory is null)
70+
{
71+
// First file actually backed up: announce the folder once. Covers every upgrade write point
72+
// (source, project, assets) and entry point (compiler, Game Studio) since all funnel here.
73+
backupDirectory = Path.Combine(rootDirectory, backupFolderName);
74+
if (log is not null)
75+
log.Info($"Upgrade: backing up the originals of modified files to [{backupDirectory}]. Review the changes before building.");
76+
}
77+
var target = Path.Combine(backupDirectory, relative);
78+
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
79+
File.Copy(fullPath, target, overwrite: true);
80+
}
81+
}
82+
83+
private static bool ContainsSegment(string relativePath, string segment)
84+
{
85+
foreach (var part in relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
86+
{
87+
if (string.Equals(part, segment, StringComparison.OrdinalIgnoreCase))
88+
return true;
89+
}
90+
return false;
91+
}
92+
}

sources/core/Stride.Core.Design/Solutions/Solution.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,18 +140,20 @@ public Solution Clone()
140140
/// <summary>
141141
/// Saves this instance to the <see cref="FullPath"/> path.
142142
/// </summary>
143-
public void Save()
143+
/// <param name="onBeforeOverwrite">Invoked with the path just before an existing file is overwritten (only when its content changed).</param>
144+
public void Save(Action<string>? onBeforeOverwrite = null)
144145
{
145-
SaveAs(FullPath);
146+
SaveAs(FullPath, onBeforeOverwrite);
146147
}
147148

148149
/// <summary>
149150
/// Saves this instance to the specified path.
150151
/// </summary>
151152
/// <param name="solutionPath">The solution path.</param>
152-
public void SaveAs(string solutionPath)
153+
/// <param name="onBeforeOverwrite">Invoked with the path just before an existing file is overwritten (only when its content changed).</param>
154+
public void SaveAs(string solutionPath, Action<string>? onBeforeOverwrite = null)
153155
{
154-
SolutionSerialization.Write(this, solutionPath);
156+
SolutionSerialization.Write(this, solutionPath, onBeforeOverwrite);
155157
}
156158

157159
/// <summary>

sources/core/Stride.Core.Design/Solutions/SolutionSerialization.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public static Solution Read(string solutionFullPath, Stream stream)
5252
return ToSolution(model, solutionFullPath);
5353
}
5454

55-
public static void Write(Solution solution, string outputPath)
55+
public static void Write(Solution solution, string outputPath, Action<string>? onBeforeOverwrite = null)
5656
{
5757
// Behave like Visual Studio: a solution loaded from disk keeps everything it had (platforms, build
5858
// configurations, solution folders, projects Stride doesn't manage) and only its project list is
@@ -76,7 +76,10 @@ public static void Write(Solution solution, string outputPath)
7676
{
7777
serializer.SaveAsync(tempPath, model, CancellationToken.None).GetAwaiter().GetResult();
7878
if (!File.ReadAllBytes(outputPath).AsSpan().SequenceEqual(File.ReadAllBytes(tempPath)))
79+
{
80+
onBeforeOverwrite?.Invoke(outputPath);
7981
File.Copy(tempPath, outputPath, overwrite: true);
82+
}
8083
}
8184
finally
8285
{

0 commit comments

Comments
 (0)