KiCad PCB EDA Suite
Loading...
Searching...
No Matches
test_history_autosave.cpp
Go to the documentation of this file.
1/*
2 * This program source code file is part of KiCad, a free EDA CAD application.
3 *
4 * Copyright The KiCad Developers, see AUTHORS.txt for contributors.
5 *
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU General Public License
8 * as published by the Free Software Foundation; either version 3
9 * of the License, or (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program; if not, you may find one here:
18 * http://www.gnu.org/licenses/gpl-3.0.html
19 */
20
22
23#include <board.h>
24#include <local_history.h>
25#include <pgm_base.h>
26#include <project.h>
29
30#include <git2.h>
31
32#include <vector>
33
34#include <wx/datetime.h>
35#include <wx/dir.h>
36#include <wx/ffile.h>
37#include <wx/filefn.h>
38#include <wx/filename.h>
39#include <wx/stdpaths.h>
40
41
42namespace
43{
44// single_top handles libgit2 init in production; the QA harness does not, so tests driving
45// LOCAL_HISTORY must manage it themselves.
46struct LIBGIT2_SCOPE
47{
48 LIBGIT2_SCOPE() { git_libgit2_init(); }
49 ~LIBGIT2_SCOPE() { git_libgit2_shutdown(); }
50};
51
52
53struct SCOPED_BOOL_OVERRIDE
54{
55 explicit SCOPED_BOOL_OVERRIDE( bool& aFlag ) : m_flag( aFlag ), m_original( aFlag ) {}
56 ~SCOPED_BOOL_OVERRIDE() { m_flag = m_original; }
57
58 bool& m_flag;
59 bool m_original;
60};
61
62
63// Restore the backup location on destruction so a thrown BOOST_REQUIRE cannot leak the
64// override into later tests.
65struct SCOPED_BACKUP_LOCATION_OVERRIDE
66{
67 explicit SCOPED_BACKUP_LOCATION_OVERRIDE( BACKUP_LOCATION& aLocation ) :
68 m_location( aLocation ), m_original( aLocation )
69 {
70 }
71
72 ~SCOPED_BACKUP_LOCATION_OVERRIDE() { m_location = m_original; }
73
74 BACKUP_LOCATION& m_location;
75 BACKUP_LOCATION m_original;
76};
77
78
79// Load a project into the settings manager and unload it on destruction, keeping the global
80// active-project state isolated even when a test aborts partway through.
81struct SCOPED_PROJECT_LOAD
82{
83 SCOPED_PROJECT_LOAD( SETTINGS_MANAGER& aMgr, const wxString& aProjectFile ) : m_mgr( aMgr )
84 {
85 m_mgr.LoadProject( aProjectFile.ToStdString() );
86 }
87
88 ~SCOPED_PROJECT_LOAD() { m_mgr.UnloadProject( &m_mgr.Prj(), false ); }
89
90 SETTINGS_MANAGER& m_mgr;
91};
92
93
94// Recursively remove the directory on destruction so test failures (which throw out of
95// BOOST_REQUIRE) do not leak temp directories.
96struct SCOPED_TEMP_DIR
97{
98 explicit SCOPED_TEMP_DIR( const wxString& aPrefix )
99 {
100 wxString base = wxStandardPaths::Get().GetTempDir();
101 m_path = base + wxFileName::GetPathSeparator() + aPrefix
102 + wxString::Format( wxS( "_%lu_%ld" ),
103 static_cast<unsigned long>( ::wxGetProcessId() ),
104 static_cast<long>( wxDateTime::UNow().GetTicks() ) );
105 wxFileName::Mkdir( m_path, 0777, wxPATH_MKDIR_FULL );
106 }
107
108 ~SCOPED_TEMP_DIR()
109 {
110 if( !m_path.IsEmpty() && wxDirExists( m_path ) )
111 wxFileName::Rmdir( m_path, wxPATH_RMDIR_RECURSIVE );
112 }
113
114 const wxString& Path() const { return m_path; }
115
116 wxString m_path;
117};
118
119
120void writeTextFile( const wxString& aPath, const wxString& aContents )
121{
122 wxFFile f( aPath, wxT( "w" ) );
123 BOOST_REQUIRE( f.IsOpened() );
124 f.Write( aContents );
125 f.Close();
126}
127} // namespace
128
129
130BOOST_AUTO_TEST_SUITE( PcbHistoryAutosave )
131
132
133
142BOOST_AUTO_TEST_CASE( SaveToHistoryWithNullProjectDoesNotCrash )
143{
144 BOARD board;
145 std::vector<HISTORY_FILE_DATA> fileData;
146
147 BOOST_REQUIRE( board.GetProject() == nullptr );
148
149 BOOST_CHECK_NO_THROW( board.SaveToHistory( wxS( "/tmp/anywhere" ), fileData ) );
150 BOOST_CHECK( fileData.empty() );
151}
152
153
158BOOST_AUTO_TEST_CASE( SaveToHistoryUnsavedBoardProducesNothing )
159{
161
162 wxString tempDir = wxStandardPaths::Get().GetTempDir();
163 wxString projectPath = tempDir + wxFileName::GetPathSeparator() + wxS( "pcb_autosave.kicad_pro" );
164
165 mgr.LoadProject( projectPath.ToStdString() );
166
167 BOARD board;
168 board.SetProject( &mgr.Prj() );
169
170 std::vector<HISTORY_FILE_DATA> fileData;
171
172 BOOST_REQUIRE( board.GetFileName().IsEmpty() );
173 BOOST_CHECK_NO_THROW( board.SaveToHistory( mgr.Prj().GetProjectPath(), fileData ) );
174 BOOST_CHECK( fileData.empty() );
175
176 // Detach project before BOARD destruction so design settings ownership unwinds cleanly
177 board.ClearProject();
178 mgr.UnloadProject( &mgr.Prj(), false );
179}
180
181
189BOOST_AUTO_TEST_CASE( NoSnapshotWithoutProjectFile )
190{
191 LIBGIT2_SCOPE libgit;
192
193 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
194 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
195 backupEnabled = true;
196
197 SCOPED_TEMP_DIR notAProject( wxS( "kicad_qa_no_project" ) );
198 const wxString& path = notAProject.Path();
199
200 // Drop a board file in but no .kicad_pro - this mirrors saving a board to /tmp
201 // from standalone pcbnew.
202 wxString boardPath = path + wxFileName::GetPathSeparator() + wxS( "stray.kicad_pcb" );
203 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108))\n" ) );
204
205 LOCAL_HISTORY history;
206
207 BOOST_CHECK( !history.Init( path ) );
208 BOOST_CHECK( !history.CommitFullProjectSnapshot( path, wxS( "PCB Save" ) ) );
209 BOOST_CHECK( !history.TagSave( path, wxS( "pcb" ) ) );
210
211 // No .history directory should have been created.
212 wxString historyDir = path + wxFileName::GetPathSeparator() + wxS( ".history" );
213 BOOST_CHECK( !wxDirExists( historyDir ) );
214}
215
216
224BOOST_AUTO_TEST_CASE( CommitFullProjectSnapshotHandlesSubdirectories )
225{
226 LIBGIT2_SCOPE libgit;
227
228 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
229 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
230 backupEnabled = true;
231
232 SCOPED_TEMP_DIR project( wxS( "kicad_qa_subdirs" ) );
233 const wxString& path = project.Path();
234
235 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "subdirs.kicad_pro" ), wxS( "{}\n" ) );
236 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "subdirs.kicad_pcb" ),
237 wxS( "(kicad_pcb (version 20240108))\n" ) );
238
239 // Create a subdirectory whose contents will likely be collected before files at the
240 // project root, exercising the subdirectory-first iteration order.
241 wxString subDir = path + wxFileName::GetPathSeparator() + wxS( "libs" );
242 BOOST_REQUIRE( wxFileName::Mkdir( subDir, 0777, wxPATH_MKDIR_FULL ) );
243 writeTextFile( subDir + wxFileName::GetPathSeparator() + wxS( "fp.kicad_mod" ),
244 wxS( "(footprint test)\n" ) );
245
246 LOCAL_HISTORY history;
247 BOOST_REQUIRE( history.CommitFullProjectSnapshot( path, wxS( "Initial" ) ) );
248
249 // History must have been created at the project root, not at the subdirectory.
250 wxString historyDir = path + wxFileName::GetPathSeparator() + wxS( ".history" );
251 BOOST_CHECK( wxDirExists( historyDir ) );
252 BOOST_CHECK( !wxDirExists( subDir + wxFileName::GetPathSeparator() + wxS( ".history" ) ) );
253
254 wxString headBefore = history.GetHeadHash( path );
255 BOOST_REQUIRE( !headBefore.IsEmpty() );
256
257 // Mutate a file in the subdirectory to ensure the next snapshot has work to do.
258 writeTextFile( subDir + wxFileName::GetPathSeparator() + wxS( "fp.kicad_mod" ),
259 wxS( "(footprint test (modified))\n" ) );
260
261 // CommitFullProjectSnapshot must commit using the project root, not derive a wrong
262 // root from the first file in the recursive collection.
263 BOOST_CHECK( history.CommitFullProjectSnapshot( path, wxS( "PCB Save" ) ) );
264
265 wxString headAfter = history.GetHeadHash( path );
266 BOOST_REQUIRE( !headAfter.IsEmpty() );
267 BOOST_CHECK( headBefore != headAfter );
268}
269
270
271// Regression test for https://gitlab.com/kicad/code/kicad/-/issues/24016
272BOOST_AUTO_TEST_CASE( RestoreCommitPreservesZipBackupsDirectory )
273{
274 LIBGIT2_SCOPE libgit;
275
276 // LOCAL_HISTORY early-exits when backups are disabled.
277 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
278 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
279 backupEnabled = true;
280
281 SCOPED_TEMP_DIR tempProject( wxS( "kicad_qa_issue24016" ) );
282 const wxString& projectPath = tempProject.Path();
283
284 wxString boardPath =
285 projectPath + wxFileName::GetPathSeparator() + wxS( "issue24016.kicad_pcb" );
286 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108))\n" ) );
287
288 // LOCAL_HISTORY refuses to operate on directories without a project file, so seed
289 // the fixture with a minimal one to mirror a real KiCad project layout.
290 wxString projectFile =
291 projectPath + wxFileName::GetPathSeparator() + wxS( "issue24016.kicad_pro" );
292 writeTextFile( projectFile, wxS( "{}\n" ) );
293
294 LOCAL_HISTORY history;
295 BOOST_REQUIRE( history.CommitFullProjectSnapshot( projectPath, wxS( "Initial" ) ) );
296
297 wxString headHash = history.GetHeadHash( projectPath );
298 BOOST_REQUIRE( !headHash.IsEmpty() );
299
300 // Mirror SETTINGS_MANAGER::BackupProject output: a sibling "<name>-backups" directory
301 // containing one or more .zip archives.
302 wxString backupsDir =
303 projectPath + wxFileName::GetPathSeparator() + wxS( "issue24016-backups" );
304 BOOST_REQUIRE( wxFileName::Mkdir( backupsDir, 0777, wxPATH_MKDIR_FULL ) );
305
306 wxString zipPath = backupsDir + wxFileName::GetPathSeparator()
307 + wxS( "issue24016-2026-04-22_120000.zip" );
308 writeTextFile( zipPath, wxS( "pretend-zip-contents" ) );
309
310 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108) (dirty yes))\n" ) );
311
312 BOOST_REQUIRE( history.RestoreCommit( projectPath, headHash, nullptr ) );
313
314 BOOST_CHECK_MESSAGE( wxDirExists( backupsDir ),
315 "zip backups directory must survive RestoreCommit" );
316 BOOST_CHECK_MESSAGE( wxFileExists( zipPath ),
317 ".zip archive inside the backups directory must survive RestoreCommit" );
318}
319
320
334BOOST_AUTO_TEST_CASE( RestoreCommitPreservesNestedProject )
335{
336 LIBGIT2_SCOPE libgit;
337
338 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
339 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
340 backupEnabled = true;
341
342 SCOPED_TEMP_DIR tempProject( wxS( "kicad_qa_nested_project" ) );
343 const wxString& projectPath = tempProject.Path();
344
345 // Parent project (projectA): minimal .kicad_pro plus a board file.
346 wxString parentPro = projectPath + wxFileName::GetPathSeparator() + wxS( "projectA.kicad_pro" );
347 wxString parentPcb = projectPath + wxFileName::GetPathSeparator() + wxS( "projectA.kicad_pcb" );
348 writeTextFile( parentPro, wxS( "{}\n" ) );
349 writeTextFile( parentPcb, wxS( "(kicad_pcb (version 20240108))\n" ) );
350
351 LOCAL_HISTORY history;
352 BOOST_REQUIRE( history.CommitFullProjectSnapshot( projectPath, wxS( "Initial" ) ) );
353
354 wxString headHash = history.GetHeadHash( projectPath );
355 BOOST_REQUIRE( !headHash.IsEmpty() );
356
357 // Now drop a nested project under projectA/. The user's scenario was that this nested
358 // project was added AFTER the parent's snapshot was committed - so its files are not in
359 // the restored commit, and a naive restore would propose them for deletion.
360 wxString nestedDir = projectPath + wxFileName::GetPathSeparator() + wxS( "projectB" );
361 BOOST_REQUIRE( wxFileName::Mkdir( nestedDir, 0777, wxPATH_MKDIR_FULL ) );
362
363 wxString nestedPro = nestedDir + wxFileName::GetPathSeparator() + wxS( "projectB.kicad_pro" );
364 wxString nestedPcb = nestedDir + wxFileName::GetPathSeparator() + wxS( "projectB.kicad_pcb" );
365 wxString nestedSch = nestedDir + wxFileName::GetPathSeparator() + wxS( "projectB.kicad_sch" );
366 writeTextFile( nestedPro, wxS( "{ \"nested\": true }\n" ) );
367 writeTextFile( nestedPcb, wxS( "(kicad_pcb (version 20240108) (nested yes))\n" ) );
368 writeTextFile( nestedSch, wxS( "(kicad_sch (version 20240108))\n" ) );
369
370 // Modify the parent board so the restore actually has work to do.
371 writeTextFile( parentPcb, wxS( "(kicad_pcb (version 20240108) (dirty yes))\n" ) );
372
373 BOOST_REQUIRE( history.RestoreCommit( projectPath, headHash, nullptr ) );
374
375 // The nested project's directory and every one of its files MUST survive the restore.
376 BOOST_CHECK_MESSAGE( wxDirExists( nestedDir ),
377 "nested project directory must survive RestoreCommit" );
378 BOOST_CHECK_MESSAGE( wxFileExists( nestedPro ),
379 "nested .kicad_pro must survive RestoreCommit" );
380 BOOST_CHECK_MESSAGE( wxFileExists( nestedPcb ),
381 "nested .kicad_pcb must survive RestoreCommit" );
382 BOOST_CHECK_MESSAGE( wxFileExists( nestedSch ),
383 "nested .kicad_sch must survive RestoreCommit" );
384
385 // Parent project files were correctly restored.
386 BOOST_CHECK( wxFileExists( parentPro ) );
387 BOOST_CHECK( wxFileExists( parentPcb ) );
388}
389
390
396BOOST_AUTO_TEST_CASE( RestoreCommitRetainsTimestampedBackup )
397{
398 LIBGIT2_SCOPE libgit;
399
400 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
401 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
402 backupEnabled = true;
403
404 SCOPED_TEMP_DIR tempProject( wxS( "kicad_qa_retained_backup" ) );
405 const wxString& projectPath = tempProject.Path();
406
407 wxString boardPath = projectPath + wxFileName::GetPathSeparator() + wxS( "rb.kicad_pcb" );
408 wxString projectFile = projectPath + wxFileName::GetPathSeparator() + wxS( "rb.kicad_pro" );
409 writeTextFile( projectFile, wxS( "{}\n" ) );
410 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108))\n" ) );
411
412 LOCAL_HISTORY history;
413 BOOST_REQUIRE( history.CommitFullProjectSnapshot( projectPath, wxS( "Initial" ) ) );
414
415 wxString headHash = history.GetHeadHash( projectPath );
416 BOOST_REQUIRE( !headHash.IsEmpty() );
417
418 // Mutate the board so restore has work to do (and produces a backup).
419 writeTextFile( boardPath, wxS( "(kicad_pcb (version 20240108) (dirty yes))\n" ) );
420
421 BOOST_REQUIRE( history.RestoreCommit( projectPath, headHash, nullptr ) );
422
423 // Backups land at a SIBLING path (aProjectPath + "_restore_backup_<ts>"), so look in
424 // the parent directory of the project. Same convention as the legacy "_restore_backup".
425 wxString parentDir = wxFileName( projectPath ).GetPath();
426 wxString leafPrefix = wxFileName( projectPath ).GetFullName() + wxS( "_restore_backup_" );
427
428 wxDir dir( parentDir );
429 BOOST_REQUIRE( dir.IsOpened() );
430
431 wxString retainedBackup;
432 bool foundLegacyBackup = false;
433
434 wxString name;
435 for( bool cont = dir.GetFirst( &name, wxEmptyString, wxDIR_DIRS ); cont;
436 cont = dir.GetNext( &name ) )
437 {
438 if( name.StartsWith( leafPrefix ) )
439 {
440 retainedBackup = parentDir + wxFileName::GetPathSeparator() + name;
441
442 // No colons - Windows path-safe.
443 BOOST_CHECK_MESSAGE( name.Find( ':' ) == wxNOT_FOUND,
444 "retained backup directory name must not contain ':' "
445 "(Windows-illegal in path components)" );
446 }
447 else if( name == wxFileName( projectPath ).GetFullName() + wxS( "_restore_backup" ) )
448 {
449 foundLegacyBackup = true;
450 }
451 }
452
454 !retainedBackup.IsEmpty(),
455 "RestoreCommit must retain a timestamped _restore_backup_<ts>/ sibling directory" );
457 !foundLegacyBackup,
458 "RestoreCommit must not leave the legacy non-timestamped _restore_backup/ behind" );
459
460 // Clean up the retained backup so the test does not leak files into /tmp.
461 if( !retainedBackup.IsEmpty() && wxDirExists( retainedBackup ) )
462 wxFileName::Rmdir( retainedBackup, wxPATH_RMDIR_RECURSIVE );
463}
464
465
469BOOST_AUTO_TEST_CASE( CommitFullProjectSnapshotExcludesNonKiCadFiles )
470{
471 LIBGIT2_SCOPE libgit;
472
473 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
474 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
475 backupEnabled = true;
476
477 SCOPED_TEMP_DIR project( wxS( "kicad_qa_privacy" ) );
478 const wxString& path = project.Path();
479
480 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
481 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
482 wxS( "(kicad_pcb (version 20240108))\n" ) );
483 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_sch" ),
484 wxS( "(kicad_sch (version 20240108))\n" ) );
485
486 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "passwords.txt" ), wxS( "secret\n" ) );
487 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "datasheet.pdf" ), wxS( "fake pdf bytes\n" ) );
488 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "notes.md" ), wxS( "personal notes\n" ) );
489
490 wxString subDir = path + wxFileName::GetPathSeparator() + wxS( "docs" );
491 BOOST_REQUIRE( wxFileName::Mkdir( subDir, 0777, wxPATH_MKDIR_FULL ) );
492 writeTextFile( subDir + wxFileName::GetPathSeparator() + wxS( "manual.txt" ), wxS( "irrelevant\n" ) );
493
494 LOCAL_HISTORY history;
495 BOOST_REQUIRE( history.CommitFullProjectSnapshot( path, wxS( "Close" ) ) );
496
497 wxString hist = path + wxFileName::GetPathSeparator() + wxS( ".history" );
498 git_repository* repo = nullptr;
499 BOOST_REQUIRE_EQUAL( git_repository_open( &repo, hist.mb_str().data() ), 0 );
500
501 git_oid head_oid;
502 BOOST_REQUIRE_EQUAL( git_reference_name_to_id( &head_oid, repo, "HEAD" ), 0 );
503
504 git_commit* head = nullptr;
505 BOOST_REQUIRE_EQUAL( git_commit_lookup( &head, repo, &head_oid ), 0 );
506
507 git_tree* tree = nullptr;
508 BOOST_REQUIRE_EQUAL( git_commit_tree( &tree, head ), 0 );
509
510 std::vector<std::string> committedPaths;
511 git_tree_walk(
512 tree, GIT_TREEWALK_PRE,
513 []( const char* root, const git_tree_entry* entry, void* payload ) -> int
514 {
515 auto* paths = static_cast<std::vector<std::string>*>( payload );
516
517 if( git_tree_entry_type( entry ) == GIT_OBJECT_BLOB )
518 paths->push_back( std::string( root ) + git_tree_entry_name( entry ) );
519
520 return 0;
521 },
522 &committedPaths );
523
524 git_tree_free( tree );
525 git_commit_free( head );
526 git_repository_free( repo );
527
528 auto contains = [&]( const std::string& s )
529 {
530 return std::find( committedPaths.begin(), committedPaths.end(), s ) != committedPaths.end();
531 };
532
533 BOOST_CHECK_MESSAGE( contains( "p.kicad_pro" ), "kicad_pro must be committed" );
534 BOOST_CHECK_MESSAGE( contains( "p.kicad_pcb" ), "kicad_pcb must be committed" );
535 BOOST_CHECK_MESSAGE( contains( "p.kicad_sch" ), "kicad_sch must be committed" );
536
537 BOOST_CHECK_MESSAGE( !contains( "passwords.txt" ), "passwords.txt must NOT appear in history" );
538 BOOST_CHECK_MESSAGE( !contains( "datasheet.pdf" ), "datasheet.pdf must NOT appear in history" );
539 BOOST_CHECK_MESSAGE( !contains( "notes.md" ), "notes.md must NOT appear in history" );
540 BOOST_CHECK_MESSAGE( !contains( "docs/manual.txt" ), "subdirectory user content must NOT appear in history" );
541}
542
543
548BOOST_AUTO_TEST_CASE( FirstAutosaveSkipsCommitWhenStagedMatchesDisk )
549{
550 LIBGIT2_SCOPE libgit;
551
552 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
553 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
554 backupEnabled = true;
555
556 SCOPED_TEMP_DIR project( wxS( "kicad_qa_first_idle_autosave" ) );
557 const wxString& path = project.Path();
558
559 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
560 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
561 wxS( "(kicad_pcb (version 20240108))\n" ) );
562
563 LOCAL_HISTORY history;
564
565 // Mutating this between calls simulates the user editing the in-memory document.
566 std::string inMemoryContent = "(kicad_pcb (version 20240108))\n";
567
568 auto saver = [&inMemoryContent]( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
569 {
570 HISTORY_FILE_DATA entry;
571 entry.relativePath = wxS( "p.kicad_pcb" );
572 entry.content = inMemoryContent;
573 aFileData.push_back( std::move( entry ) );
574 };
575
576 history.RegisterSaver( &history, saver );
577
578 // Saver output matches disk, must skip (autosave path: empty tagFileType).
579 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString ) );
580 history.WaitForPendingSave();
581
582 wxString histDir = path + wxFileName::GetPathSeparator() + wxS( ".history" );
583 BOOST_CHECK( wxDirExists( histDir ) );
584
585 wxString head = history.GetHeadHash( path );
586 BOOST_CHECK_MESSAGE( head.IsEmpty(), "no untagged HEAD should exist after an idle first save" );
587
588 // Real edit, must commit.
589 inMemoryContent = "(kicad_pcb (version 20240108) (edited yes))\n";
590
591 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Autosave" ), wxEmptyString ) );
592 history.WaitForPendingSave();
593
594 head = history.GetHeadHash( path );
595 BOOST_CHECK_MESSAGE( !head.IsEmpty(), "in-memory edits diverging from disk must produce a commit" );
596
597 history.UnregisterSaver( &history );
598}
599
600
606BOOST_AUTO_TEST_CASE( FirstManualSaveAlwaysCommitsOnFreshProject )
607{
608 LIBGIT2_SCOPE libgit;
609
610 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
611 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
612 backupEnabled = true;
613
614 SCOPED_TEMP_DIR project( wxS( "kicad_qa_first_manual_save" ) );
615 const wxString& path = project.Path();
616
617 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
618 writeTextFile( path + wxFileName::GetPathSeparator() + wxS( "p.kicad_pcb" ),
619 wxS( "(kicad_pcb (version 20240108))\n" ) );
620
621 LOCAL_HISTORY history;
622
623 std::string inMemoryContent = "(kicad_pcb (version 20240108))\n";
624
625 auto saver = [&inMemoryContent]( const wxString&, std::vector<HISTORY_FILE_DATA>& aFileData )
626 {
627 HISTORY_FILE_DATA entry;
628 entry.relativePath = wxS( "p.kicad_pcb" );
629 entry.content = inMemoryContent;
630 aFileData.push_back( std::move( entry ) );
631 };
632
633 history.RegisterSaver( &history, saver );
634
635 // Manual save (non-empty tagFileType) on fresh project: commit even when staged matches disk.
636 BOOST_REQUIRE( history.RunRegisteredSaversAndCommit( path, wxS( "Manual Save" ), wxS( "pcb" ) ) );
637
638 wxString head = history.GetHeadHash( path );
639 BOOST_CHECK_MESSAGE( !head.IsEmpty(), "manual save on a fresh project must commit even when staged matches disk" );
640
641 history.UnregisterSaver( &history );
642}
643
644
645// A content-identical autosave with a newer mtime (cloud-sync touch) must not be flagged
646// stale, or recovery prompts fire on every open with nothing to restore (issue 24126).
647BOOST_AUTO_TEST_CASE( CloudSyncTouchedAutosaveFalselyFlaggedStale )
648{
649 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
650 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
651 backupEnabled = true;
652
654 SCOPED_BACKUP_LOCATION_OVERRIDE restoreLocation( location );
656
657 SCOPED_TEMP_DIR project( wxS( "kicad_qa_cloudsync_autosave" ) );
658 const wxString& path = project.Path();
659 const wxString sep = wxFileName::GetPathSeparator();
660
661 // FindStaleAutosaveFiles resolves the autosave root through the active project, so it must
662 // be loaded; the .kicad_pro must exist on disk first for LoadProject to succeed.
663 writeTextFile( path + sep + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
664
666 SCOPED_PROJECT_LOAD loadedProject( mgr, path + sep + wxS( "p.kicad_pro" ) );
667
668 const wxString sourcePath = path + sep + wxS( "p.kicad_pcb" );
669 const wxString autosavePath = path + sep + wxS( "_autosave-p.kicad_pcb" );
670 const wxString boardContent = wxS( "(kicad_pcb (version 20240108))\n" );
671
672 // Byte-identical source and "_autosave-" companion, mirroring a clean save.
673 writeTextFile( sourcePath, boardContent );
674 writeTextFile( autosavePath, boardContent );
675
676 LOCAL_HISTORY history;
677
678 // Cloud-sync touch gives the identical autosave a strictly newer mtime.
679 wxDateTime srcMtime = wxFileName( sourcePath ).GetModificationTime();
680 wxDateTime newerMtime = srcMtime + wxTimeSpan::Seconds( 60 );
681 BOOST_REQUIRE( wxFileName( autosavePath ).SetTimes( &newerMtime, &newerMtime, nullptr ) );
682
683 std::vector<wxString> exts{ wxS( "kicad_pcb" ) };
684 auto stale = history.FindStaleAutosaveFiles( path, exts );
685
686 BOOST_CHECK_MESSAGE( stale.empty(),
687 "Content-identical autosave with newer mtime was flagged stale, "
688 "triggering a spurious recovery prompt (issue 24126)" );
689}
690
691
692// Counterpart to the above: an autosave whose content genuinely differs (a real unsaved edit)
693// must still be flagged stale so recovery continues to fire.
694BOOST_AUTO_TEST_CASE( DivergentAutosaveStillFlaggedStale )
695{
696 bool& backupEnabled = Pgm().GetCommonSettings()->m_Backup.enabled;
697 SCOPED_BOOL_OVERRIDE restoreBackupFlag( backupEnabled );
698 backupEnabled = true;
699
701 SCOPED_BACKUP_LOCATION_OVERRIDE restoreLocation( location );
703
704 SCOPED_TEMP_DIR project( wxS( "kicad_qa_divergent_autosave" ) );
705 const wxString& path = project.Path();
706 const wxString sep = wxFileName::GetPathSeparator();
707
708 writeTextFile( path + sep + wxS( "p.kicad_pro" ), wxS( "{}\n" ) );
709
711 SCOPED_PROJECT_LOAD loadedProject( mgr, path + sep + wxS( "p.kicad_pro" ) );
712
713 const wxString sourcePath = path + sep + wxS( "p.kicad_pcb" );
714 const wxString autosavePath = path + sep + wxS( "_autosave-p.kicad_pcb" );
715
716 // Autosave content diverges from the source, a genuine unsaved edit to recover.
717 writeTextFile( sourcePath, wxS( "(kicad_pcb (version 20240108))\n" ) );
718 writeTextFile( autosavePath, wxS( "(kicad_pcb (version 20240108) (edited yes))\n" ) );
719
720 LOCAL_HISTORY history;
721
722 wxDateTime srcMtime = wxFileName( sourcePath ).GetModificationTime();
723 wxDateTime newerMtime = srcMtime + wxTimeSpan::Seconds( 60 );
724 BOOST_REQUIRE( wxFileName( autosavePath ).SetTimes( &newerMtime, &newerMtime, nullptr ) );
725
726 std::vector<wxString> exts{ wxS( "kicad_pcb" ) };
727 auto stale = history.FindStaleAutosaveFiles( path, exts );
728
729 BOOST_CHECK_MESSAGE( stale.size() == 1,
730 "Genuinely divergent autosave must still be flagged stale for recovery" );
731}
732
733
const char * name
Information pertinent to a Pcbnew printed circuit board.
Definition board.h:323
void SaveToHistory(const wxString &aProjectPath, std::vector< HISTORY_FILE_DATA > &aFileData)
Serialize board into HISTORY_FILE_DATA for non-blocking history commit.
Definition board.cpp:3970
void SetProject(PROJECT *aProject, bool aReferenceOnly=false)
Link a board to a given project.
Definition board.cpp:213
const wxString & GetFileName() const
Definition board.h:360
void ClearProject()
Definition board.cpp:254
PROJECT * GetProject() const
Definition board.h:587
AUTO_BACKUP m_Backup
Simple local history manager built on libgit2.
bool TagSave(const wxString &aProjectPath, const wxString &aFileType)
Tag a manual save in the local history repository.
bool RunRegisteredSaversAndCommit(const wxString &aProjectPath, const wxString &aTitle, const wxString &aTagFileType=wxEmptyString)
Run all registered savers and, if any staged changes differ from HEAD, create a commit.
std::vector< std::pair< wxString, wxString > > FindStaleAutosaveFiles(const wxString &aProjectPath, const std::vector< wxString > &aExtensions) const
Enumerate autosave files newer than their corresponding source files for the project at aProjectPath,...
wxString GetHeadHash(const wxString &aProjectPath)
Return the current head commit hash.
bool RestoreCommit(const wxString &aProjectPath, const wxString &aHash, wxWindow *aParent=nullptr)
Restore the project files to the state recorded by the given commit hash.
void WaitForPendingSave()
Block until any pending background save completes.
void RegisterSaver(const void *aSaverObject, const std::function< void(const wxString &, std::vector< HISTORY_FILE_DATA > &)> &aSaver)
Register a saver callback invoked during autosave history commits.
bool Init(const wxString &aProjectPath)
Initialize the local history repository for the given project path.
bool CommitFullProjectSnapshot(const wxString &aProjectPath, const wxString &aTitle)
Commit a snapshot of the entire project directory (excluding the .history directory and ignored trans...
void UnregisterSaver(const void *aSaverObject)
Unregister a previously registered saver callback.
virtual COMMON_SETTINGS * GetCommonSettings() const
Definition pgm_base.cpp:541
virtual SETTINGS_MANAGER & GetSettingsManager() const
Definition pgm_base.h:130
virtual const wxString GetProjectPath() const
Return the full path of the project.
Definition project.cpp:187
bool LoadProject(const wxString &aFullPath, bool aSetActive=true)
Load a project or sets up a new project with a specified path.
bool UnloadProject(PROJECT *aProject, bool aSave=true)
Save, unload and unregister the given PROJECT.
PROJECT & Prj() const
A helper while we are not MDI-capable – return the one and only project.
BACKUP_LOCATION
@ PROJECT_DIR
Inside the project directory (default)
PGM_BASE & Pgm()
The global program "get" accessor.
see class PGM_BASE
BACKUP_LOCATION location
Where backups, history, and autosave files live.
bool enabled
Automatically back up the project when files are saved.
Data produced by a registered saver on the UI thread, consumed by either the background local-history...
std::string content
Serialized content (mutually exclusive with sourcePath)
wxString relativePath
Destination path relative to the project root.
BOOST_AUTO_TEST_CASE(HorizontalAlignment)
BOOST_AUTO_TEST_SUITE(CadstarPartParser)
BOOST_REQUIRE(intersection.has_value()==c.ExpectedIntersection.has_value())
BOOST_AUTO_TEST_SUITE_END()
BOOST_AUTO_TEST_CASE(SaveToHistoryWithNullProjectDoesNotCrash)
Regression test for https://gitlab.com/kicad/code/kicad/-/issues/23737.
std::string path
BOOST_CHECK_MESSAGE(totalMismatches==0, std::to_string(totalMismatches)+" board(s) with strategy disagreements")
VECTOR2I location