1818 _remove_file_by_path ,
1919 github_pull ,
2020 _github_pull_delta ,
21+ _github_pull_full ,
2122 _apply_delta_changes ,
2223 _upsert_source_file ,
2324 _upsert_resource_variant ,
@@ -691,6 +692,14 @@ def test_delta_pull_applies_changes(self, mock_now, mock_get_root, mock_parse, m
691692 mock_parse .return_value = ('' , {'projectType' : 'native' })
692693 mock_get_root .return_value = 'src/main.c'
693694
695+ # _apply_delta_changes is responsible for stamping the new SHA inside
696+ # its atomic block; simulate that here.
697+ def fake_apply (project , repo , root , manifest , changed_files , new_commit_sha = None ):
698+ project .github_last_commit = new_commit_sha
699+ project .github_last_sync = mock_now .return_value
700+ project .save ()
701+ mock_apply .side_effect = fake_apply
702+
694703 result = _github_pull_delta (self .user , self .project , self .repo , 'newsha' )
695704 self .assertTrue (result )
696705 mock_apply .assert_called_once ()
@@ -1197,4 +1206,182 @@ def test_does_not_save_when_kind_already_correct(self, MockRI):
11971206 _sync_resource_files_from_manifest (project , media_map , existing_resources )
11981207
11991208 self .assertEqual (existing_resource .kind , 'png' )
1200- existing_resource .save .assert_not_called ()
1209+ existing_resource .save .assert_not_called ()
1210+
1211+
1212+ class GithubPullDeltaAtomicityTest (TestCase ):
1213+ """Verifies that github_last_commit and github_last_sync are persisted in
1214+ the same atomic transaction as the file-content changes, so a partial
1215+ failure (or a worker kill) can never leave the project with new files but
1216+ the old SHA, which would cause the next pull to re-apply the same delta.
1217+ """
1218+
1219+ def setUp (self ):
1220+ self .user = mock .MagicMock ()
1221+ self .project = mock .MagicMock ()
1222+ self .project .github_last_commit = 'oldsha'
1223+ self .project .resources_path = 'resources'
1224+ self .project .project_type = 'native'
1225+ self .repo = mock .MagicMock ()
1226+
1227+ @mock .patch ('ide.tasks.git._apply_delta_changes' )
1228+ @mock .patch ('ide.tasks.git.validate_resources_against_tree' )
1229+ @mock .patch ('ide.tasks.git.parse_manifest_from_tree' )
1230+ @mock .patch ('ide.tasks.git.get_root_path' )
1231+ @mock .patch ('ide.tasks.git.now' )
1232+ def test_delta_pull_passes_new_commit_sha_to_apply_delta_changes (
1233+ self , mock_now , mock_get_root , mock_parse , mock_validate , mock_apply ):
1234+ """The new SHA must be passed through so _apply_delta_changes can
1235+ stamp it inside the same atomic block as the file mutations."""
1236+ mock_now .return_value = '2025-01-01T00:00:00Z'
1237+ comparison = mock .MagicMock ()
1238+ comparison .ahead_by = 3
1239+ comparison .files = [mock .MagicMock (filename = 'src/main.c' , status = 'modified' )]
1240+ self .repo .compare .return_value = comparison
1241+
1242+ mock_commit = mock .MagicMock ()
1243+ mock_commit .tree .sha = 'treesha'
1244+ self .repo .get_git_commit .return_value = mock_commit
1245+ mock_tree = mock .MagicMock ()
1246+ mock_tree .tree = []
1247+ self .repo .get_git_tree .return_value = mock_tree
1248+
1249+ mock_parse .return_value = ('' , {'projectType' : 'native' })
1250+ mock_get_root .return_value = 'src/main.c'
1251+
1252+ _github_pull_delta (self .user , self .project , self .repo , 'newsha' )
1253+
1254+ mock_apply .assert_called_once ()
1255+ args , kwargs = mock_apply .call_args
1256+ self .assertEqual (args [5 ], 'newsha' )
1257+
1258+ @mock .patch ('ide.tasks.git._sync_resource_files_from_manifest' )
1259+ @mock .patch ('ide.tasks.git.load_manifest_dict' )
1260+ def test_apply_delta_changes_updates_github_last_commit_inside_atomic (
1261+ self , mock_load , mock_sync ):
1262+ """When called with new_commit_sha, the SHA and last_sync must be
1263+ assigned and saved *inside* the transaction.atomic() block."""
1264+ mock_load .return_value = ({}, {}, {})
1265+ project = mock .MagicMock ()
1266+ project .resources_path = 'resources'
1267+ project .project_type = 'native'
1268+ project .github_last_commit = 'oldsha'
1269+ repo = mock .MagicMock ()
1270+
1271+ manifest = {'projectType' : 'native' , 'resources' : {'media' : []}}
1272+
1273+ # Use a context-manager spy that records the project state at __enter__
1274+ # and __exit__ so we can prove the SHA was set before the block exited.
1275+ captured = {}
1276+
1277+ class SpyAtomic :
1278+ def __enter__ (self_inner ):
1279+ captured ['enter_github_last_commit' ] = project .github_last_commit
1280+ return self_inner
1281+
1282+ def __exit__ (self_inner , * args ):
1283+ captured ['exit_github_last_commit' ] = project .github_last_commit
1284+ return False
1285+
1286+ with mock .patch ('ide.tasks.git.transaction.atomic' , return_value = SpyAtomic ()):
1287+ project .source_files .all .return_value = []
1288+ project .resources .all .return_value = []
1289+ _apply_delta_changes (project , repo , '' , manifest , [], new_commit_sha = 'newsha' )
1290+
1291+ self .assertEqual (captured ['enter_github_last_commit' ], 'oldsha' )
1292+ self .assertEqual (captured ['exit_github_last_commit' ], 'newsha' )
1293+ self .assertIsNotNone (project .github_last_sync )
1294+
1295+ @mock .patch ('ide.tasks.git._sync_resource_files_from_manifest' )
1296+ @mock .patch ('ide.tasks.git.load_manifest_dict' )
1297+ def test_apply_delta_changes_does_not_touch_sha_when_omitted (
1298+ self , mock_load , mock_sync ):
1299+ """When new_commit_sha is None (legacy call sites), the SHA must
1300+ not be silently overwritten."""
1301+ mock_load .return_value = ({}, {}, {})
1302+ project = mock .MagicMock ()
1303+ project .resources_path = 'resources'
1304+ project .project_type = 'native'
1305+ project .github_last_commit = 'oldsha'
1306+ repo = mock .MagicMock ()
1307+
1308+ manifest = {'projectType' : 'native' , 'resources' : {'media' : []}}
1309+
1310+ with mock .patch ('ide.tasks.git.transaction' ):
1311+ project .source_files .all .return_value = []
1312+ project .resources .all .return_value = []
1313+ _apply_delta_changes (project , repo , '' , manifest , [])
1314+
1315+ self .assertEqual (project .github_last_commit , 'oldsha' )
1316+
1317+ @mock .patch ('ide.tasks.git._apply_delta_changes' )
1318+ @mock .patch ('ide.tasks.git.validate_resources_against_tree' )
1319+ @mock .patch ('ide.tasks.git.parse_manifest_from_tree' )
1320+ @mock .patch ('ide.tasks.git.get_root_path' )
1321+ @mock .patch ('ide.tasks.git.now' )
1322+ @mock .patch ('ide.tasks.git.transaction.atomic' )
1323+ def test_delta_pull_ahead_by_zero_saves_inside_atomic (
1324+ self , mock_atomic , mock_now , mock_get_root , mock_parse , mock_validate , mock_apply ):
1325+ """The 'no new commits' branch must also wrap its save in atomic()."""
1326+ mock_atomic .return_value .__enter__ = mock .MagicMock ()
1327+ mock_atomic .return_value .__exit__ = mock .MagicMock (return_value = False )
1328+ mock_now .return_value = '2025-01-01T00:00:00Z'
1329+ comparison = mock .MagicMock ()
1330+ comparison .ahead_by = 0
1331+ self .repo .compare .return_value = comparison
1332+
1333+ _github_pull_delta (self .user , self .project , self .repo , 'newsha' )
1334+
1335+ mock_atomic .assert_called_once ()
1336+ self .assertEqual (self .project .github_last_commit , 'newsha' )
1337+
1338+
1339+ class GithubPullFullAtomicityTest (TestCase ):
1340+ """Verifies that _github_pull_full stamps the new SHA in an atomic block
1341+ after do_import_archive, so a project never ends up with new files but
1342+ the old github_last_commit.
1343+ """
1344+
1345+ def setUp (self ):
1346+ self .user = mock .MagicMock ()
1347+ self .project = mock .MagicMock ()
1348+ self .project .github_last_commit = 'oldsha'
1349+ self .project .resources_path = 'resources'
1350+ self .project .project_type = 'native'
1351+ self .repo = mock .MagicMock ()
1352+
1353+ @mock .patch ('ide.tasks.git.do_import_archive' )
1354+ @mock .patch ('ide.tasks.git.validate_resources_against_tree' )
1355+ @mock .patch ('ide.tasks.git.parse_manifest_from_tree' )
1356+ @mock .patch ('ide.tasks.git.get_root_path' )
1357+ @mock .patch ('ide.tasks.git.urlopen' )
1358+ @mock .patch ('ide.tasks.git.now' )
1359+ @mock .patch ('ide.tasks.git.transaction.atomic' )
1360+ def test_full_pull_stamps_sha_in_atomic_block (
1361+ self , mock_atomic , mock_now , mock_urlopen , mock_get_root , mock_parse , mock_validate , mock_import ):
1362+ mock_atomic .return_value .__enter__ = mock .MagicMock ()
1363+ mock_atomic .return_value .__exit__ = mock .MagicMock (return_value = False )
1364+ mock_now .return_value = '2025-01-01T00:00:00Z'
1365+
1366+ branch = mock .MagicMock ()
1367+ branch .commit .sha = 'newsha'
1368+ self .repo .default_branch = 'main'
1369+
1370+ commit = mock .MagicMock ()
1371+ commit .tree .sha = 'treesha'
1372+ self .repo .get_git_commit .return_value = commit
1373+ tree = mock .MagicMock ()
1374+ tree .tree = []
1375+ self .repo .get_git_tree .return_value = tree
1376+
1377+ mock_parse .return_value = ('' , {'projectType' : 'native' })
1378+ mock_get_root .return_value = 'src/main.c'
1379+ mock_import .return_value = 'import_result'
1380+
1381+ result = _github_pull_full (self .user , self .project , self .repo , branch )
1382+
1383+ self .assertEqual (result , 'import_result' )
1384+ mock_atomic .assert_called_once ()
1385+ self .assertEqual (self .project .github_last_commit , 'newsha' )
1386+ self .assertEqual (self .project .github_last_sync , '2025-01-01T00:00:00Z' )
1387+ self .project .save .assert_called_once ()
0 commit comments