Skip to content

Commit d62d08f

Browse files
committed
Make github SHA update atomic with file changes
Wrap github_last_commit and github_last_sync saves in transaction.atomic() across full, delta, and no-new-commit pull paths so a partial failure never leaves the project with new files but the old SHA.
1 parent 264ebb3 commit d62d08f

2 files changed

Lines changed: 208 additions & 14 deletions

File tree

cloudpebble/ide/tasks/git.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -414,9 +414,13 @@ def _github_pull_full(user, project, repo, branch):
414414

415415
import_result = do_import_archive(project.id, u.read(), wipe_existing=True)
416416

417-
project.github_last_commit = branch.commit.sha
418-
project.github_last_sync = now()
419-
project.save()
417+
# Stamp the new SHA inside the same transaction as the archive import so
418+
# the project never ends up with new files but the old github_last_commit
419+
# (which would cause the next pull to re-apply the same archive).
420+
with transaction.atomic():
421+
project.github_last_commit = branch.commit.sha
422+
project.github_last_sync = now()
423+
project.save()
420424

421425
send_td_event('cloudpebble_github_pull', data={
422426
'data': {'repo': project.github_repo}
@@ -430,9 +434,10 @@ def _github_pull_delta(user, project, repo, new_commit_sha):
430434
comparison = repo.compare(project.github_last_commit, new_commit_sha)
431435

432436
if comparison.ahead_by == 0:
433-
project.github_last_commit = new_commit_sha
434-
project.github_last_sync = now()
435-
project.save()
437+
with transaction.atomic():
438+
project.github_last_commit = new_commit_sha
439+
project.github_last_sync = now()
440+
project.save()
436441
return False
437442

438443
commit = repo.get_git_commit(new_commit_sha)
@@ -449,11 +454,7 @@ def _github_pull_delta(user, project, repo, new_commit_sha):
449454
validate_resources_against_tree(paths_notags, manifest, project, root)
450455

451456
changed_files = comparison.files
452-
_apply_delta_changes(project, repo, root, manifest, changed_files)
453-
454-
project.github_last_commit = new_commit_sha
455-
project.github_last_sync = now()
456-
project.save()
457+
_apply_delta_changes(project, repo, root, manifest, changed_files, new_commit_sha)
457458

458459
send_td_event('cloudpebble_github_pull', data={
459460
'data': {'repo': project.github_repo}
@@ -462,12 +463,14 @@ def _github_pull_delta(user, project, repo, new_commit_sha):
462463
return True
463464

464465

465-
def _apply_delta_changes(project, repo, root, manifest, changed_files):
466+
def _apply_delta_changes(project, repo, root, manifest, changed_files, new_commit_sha=None):
466467
"""Apply incremental file changes to the project database without a full wipe.
467468
468469
Given a list of changed files from GitHub's Compare API, creates, updates,
469470
or deletes only the affected SourceFile and ResourceFile/ResourceVariant
470-
records. All changes are wrapped in a single atomic transaction.
471+
records. All changes (including the github_last_commit / github_last_sync
472+
update) are wrapped in a single atomic transaction, so the project either
473+
advances fully to ``new_commit_sha`` or not at all.
471474
"""
472475
manifest_kind = 'package.json' if 'pebble' in manifest else 'appinfo.json'
473476
resource_root = ((root + '/' if root else '') + project.resources_path).rstrip('/') + '/'
@@ -514,6 +517,10 @@ def _apply_delta_changes(project, repo, root, manifest, changed_files):
514517

515518
_sync_resource_files_from_manifest(project, media_map, existing_resources)
516519

520+
if new_commit_sha is not None:
521+
project.github_last_commit = new_commit_sha
522+
project.github_last_sync = now()
523+
517524
project.save()
518525

519526

cloudpebble/ide/tests/test_git.py

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
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

Comments
 (0)