diff options
| author | Gavin Mak <gavinmak@google.com> | 2026-04-01 23:03:09 +0000 |
|---|---|---|
| committer | LUCI <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2026-04-09 12:09:40 -0700 |
| commit | 3b0eebeccfd4447e4a50ae143f37d0f7817723be (patch) | |
| tree | 5bffc4647d4f883eac0a514ef304b8f2fd75c32c | |
| parent | 00991bfb42c4f3b0b7a50aa8f475ac1c8369924b (diff) | |
| download | git-repo-3b0eebeccfd4447e4a50ae143f37d0f7817723be.tar.gz | |
project: implement stateless sync pruning logic
Implement in-situ shallow re-fetching and garbage collection logic.
Enables repositories with sync-strategy="stateless" to reclaim disk
space by running reflog expire and git gc --prune=now if the working
tree is clean and has no local commits.
Bug: 498730431
Change-Id: I940bdc9b74da29d3f7b13566667dcddea769ebd3
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/568463
Reviewed-by: Mike Frysinger <vapier@google.com>
Tested-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
| -rw-r--r-- | project.py | 101 | ||||
| -rw-r--r-- | tests/test_project.py | 118 |
2 files changed, 219 insertions, 0 deletions
diff --git a/project.py b/project.py index 1766c9a07..35c0fe115 100644 --- a/project.py +++ b/project.py | |||
| @@ -629,6 +629,7 @@ class Project: | |||
| 629 | self.linkfiles = {} | 629 | self.linkfiles = {} |
| 630 | self.annotations = [] | 630 | self.annotations = [] |
| 631 | self.dest_branch = dest_branch | 631 | self.dest_branch = dest_branch |
| 632 | self.stateless_prune_needed = False | ||
| 632 | 633 | ||
| 633 | # This will be filled in if a project is later identified to be the | 634 | # This will be filled in if a project is later identified to be the |
| 634 | # project containing repo hooks. | 635 | # project containing repo hooks. |
| @@ -758,6 +759,18 @@ class Project: | |||
| 758 | return True | 759 | return True |
| 759 | return False | 760 | return False |
| 760 | 761 | ||
| 762 | def HasStash(self) -> bool: | ||
| 763 | """Returns True if there is a stash in the repository.""" | ||
| 764 | p = GitCommand( | ||
| 765 | self, | ||
| 766 | ["rev-parse", "--verify", "refs/stash"], | ||
| 767 | bare=True, | ||
| 768 | capture_stdout=True, | ||
| 769 | capture_stderr=True, | ||
| 770 | log_as_error=False, | ||
| 771 | ) | ||
| 772 | return p.Wait() == 0 | ||
| 773 | |||
| 761 | _userident_name = None | 774 | _userident_name = None |
| 762 | _userident_email = None | 775 | _userident_email = None |
| 763 | 776 | ||
| @@ -1241,6 +1254,67 @@ class Project: | |||
| 1241 | logger.error("error: Cannot extract archive %s: %s", tarpath, e) | 1254 | logger.error("error: Cannot extract archive %s: %s", tarpath, e) |
| 1242 | return False | 1255 | return False |
| 1243 | 1256 | ||
| 1257 | def _ShouldStatelessPrune( | ||
| 1258 | self, use_superproject: Optional[bool] = None | ||
| 1259 | ) -> bool: | ||
| 1260 | """Determines if a stateless prune should be performed. | ||
| 1261 | |||
| 1262 | Stateless pruning reclaims space by running a reflog expiration and | ||
| 1263 | garbage collection instead of an incremental fetch. It is only performed | ||
| 1264 | if the repository is clean and has no local-only state. | ||
| 1265 | """ | ||
| 1266 | if not self.Exists: | ||
| 1267 | return False | ||
| 1268 | |||
| 1269 | if self._CheckForImmutableRevision(use_superproject=use_superproject): | ||
| 1270 | return False | ||
| 1271 | |||
| 1272 | # Query the target hash from remote to see if we are up-to-date. | ||
| 1273 | target_hash = None | ||
| 1274 | if IsId(self.revisionExpr): | ||
| 1275 | target_hash = self.revisionExpr | ||
| 1276 | else: | ||
| 1277 | output = self._LsRemote(self.upstream or self.revisionExpr) | ||
| 1278 | if output: | ||
| 1279 | target_hash = output.splitlines()[0].split()[0] | ||
| 1280 | |||
| 1281 | if not target_hash: | ||
| 1282 | return False | ||
| 1283 | |||
| 1284 | try: | ||
| 1285 | local_head = self.bare_git.rev_parse("HEAD") | ||
| 1286 | except GitError: | ||
| 1287 | local_head = None | ||
| 1288 | |||
| 1289 | if target_hash == local_head: | ||
| 1290 | return False | ||
| 1291 | |||
| 1292 | # Skip if sharing objects with other projects. | ||
| 1293 | shares_objdir = self.UseAlternates or self.use_git_worktrees | ||
| 1294 | if not shares_objdir: | ||
| 1295 | for p in self.manifest.GetProjectsWithName(self.name): | ||
| 1296 | if p != self and p.objdir == self.objdir: | ||
| 1297 | shares_objdir = True | ||
| 1298 | break | ||
| 1299 | |||
| 1300 | if shares_objdir: | ||
| 1301 | return False | ||
| 1302 | |||
| 1303 | # Skip if HEAD contains any unpushed local commits. | ||
| 1304 | try: | ||
| 1305 | local_commits = self.bare_git.rev_list( | ||
| 1306 | "--count", "HEAD", "--not", "--remotes", "--tags" | ||
| 1307 | ) | ||
| 1308 | if int(local_commits[0]) > 0: | ||
| 1309 | return False | ||
| 1310 | except (GitError, IndexError, ValueError): | ||
| 1311 | return False | ||
| 1312 | |||
| 1313 | if self.IsDirty(consider_untracked=True) or self.HasStash(): | ||
| 1314 | return False | ||
| 1315 | |||
| 1316 | return True | ||
| 1317 | |||
| 1244 | def Sync_NetworkHalf( | 1318 | def Sync_NetworkHalf( |
| 1245 | self, | 1319 | self, |
| 1246 | quiet=False, | 1320 | quiet=False, |
| @@ -1318,6 +1392,11 @@ class Project: | |||
| 1318 | clone_bundle = True | 1392 | clone_bundle = True |
| 1319 | clone_filter = None | 1393 | clone_filter = None |
| 1320 | 1394 | ||
| 1395 | if self.sync_strategy == "stateless" and self._ShouldStatelessPrune( | ||
| 1396 | use_superproject | ||
| 1397 | ): | ||
| 1398 | self.stateless_prune_needed = True | ||
| 1399 | |||
| 1321 | if is_new is None: | 1400 | if is_new is None: |
| 1322 | is_new = not self.Exists | 1401 | is_new = not self.Exists |
| 1323 | if is_new: | 1402 | if is_new: |
| @@ -1602,6 +1681,23 @@ class Project: | |||
| 1602 | def _dosubmodules(): | 1681 | def _dosubmodules(): |
| 1603 | self._SyncSubmodules(quiet=True) | 1682 | self._SyncSubmodules(quiet=True) |
| 1604 | 1683 | ||
| 1684 | def _doprune() -> None: | ||
| 1685 | """Expire reflogs and run prune-now GC for stateless sync.""" | ||
| 1686 | GitCommand( | ||
| 1687 | self, | ||
| 1688 | ["reflog", "expire", "--expire=all", "--all"], | ||
| 1689 | bare=True, | ||
| 1690 | ).Wait() | ||
| 1691 | p = GitCommand( | ||
| 1692 | self, | ||
| 1693 | ["gc", "--prune=now"], | ||
| 1694 | bare=True, | ||
| 1695 | capture_stdout=True, | ||
| 1696 | capture_stderr=True, | ||
| 1697 | ) | ||
| 1698 | if p.Wait() != 0: | ||
| 1699 | logger.warning("warn: %s: stateless gc failed", self.name) | ||
| 1700 | |||
| 1605 | head = self.work_git.GetHead() | 1701 | head = self.work_git.GetHead() |
| 1606 | if head.startswith(R_HEADS): | 1702 | if head.startswith(R_HEADS): |
| 1607 | branch = head[len(R_HEADS) :] | 1703 | branch = head[len(R_HEADS) :] |
| @@ -1647,6 +1743,8 @@ class Project: | |||
| 1647 | fail(e) | 1743 | fail(e) |
| 1648 | return | 1744 | return |
| 1649 | self._CopyAndLinkFiles() | 1745 | self._CopyAndLinkFiles() |
| 1746 | if self.stateless_prune_needed: | ||
| 1747 | syncbuf.later2(self, _doprune, not verbose) | ||
| 1650 | return | 1748 | return |
| 1651 | 1749 | ||
| 1652 | if head == revid: | 1750 | if head == revid: |
| @@ -1793,6 +1891,9 @@ class Project: | |||
| 1793 | if submodules: | 1891 | if submodules: |
| 1794 | syncbuf.later1(self, _dosubmodules, not verbose) | 1892 | syncbuf.later1(self, _dosubmodules, not verbose) |
| 1795 | 1893 | ||
| 1894 | if self.stateless_prune_needed: | ||
| 1895 | syncbuf.later2(self, _doprune, not verbose) | ||
| 1896 | |||
| 1796 | def AddCopyFile(self, src, dest, topdir): | 1897 | def AddCopyFile(self, src, dest, topdir): |
| 1797 | """Mark |src| for copying to |dest| (relative to |topdir|). | 1898 | """Mark |src| for copying to |dest| (relative to |topdir|). |
| 1798 | 1899 | ||
diff --git a/tests/test_project.py b/tests/test_project.py index 501707eaf..a2d90d803 100644 --- a/tests/test_project.py +++ b/tests/test_project.py | |||
| @@ -21,6 +21,7 @@ import subprocess | |||
| 21 | import tempfile | 21 | import tempfile |
| 22 | from typing import Optional | 22 | from typing import Optional |
| 23 | import unittest | 23 | import unittest |
| 24 | from unittest import mock | ||
| 24 | 25 | ||
| 25 | import utils_for_test | 26 | import utils_for_test |
| 26 | 27 | ||
| @@ -565,3 +566,120 @@ class ManifestPropertiesFetchedCorrectly(unittest.TestCase): | |||
| 565 | 566 | ||
| 566 | fakeproj.config.SetString("manifest.platform", "auto") | 567 | fakeproj.config.SetString("manifest.platform", "auto") |
| 567 | self.assertEqual(fakeproj.manifest_platform, "auto") | 568 | self.assertEqual(fakeproj.manifest_platform, "auto") |
| 569 | |||
| 570 | |||
| 571 | class StatelessSyncTests(unittest.TestCase): | ||
| 572 | """Tests for stateless sync strategy.""" | ||
| 573 | |||
| 574 | def _get_project(self, tempdir): | ||
| 575 | manifest = mock.MagicMock() | ||
| 576 | manifest.manifestProject.depth = None | ||
| 577 | manifest.manifestProject.dissociate = False | ||
| 578 | manifest.manifestProject.clone_filter = None | ||
| 579 | manifest.is_multimanifest = False | ||
| 580 | manifest.manifestProject.config.GetBoolean.return_value = False | ||
| 581 | |||
| 582 | remote = mock.MagicMock() | ||
| 583 | remote.name = "origin" | ||
| 584 | remote.url = "http://" | ||
| 585 | |||
| 586 | proj = project.Project( | ||
| 587 | manifest=manifest, | ||
| 588 | name="test-project", | ||
| 589 | remote=remote, | ||
| 590 | gitdir=os.path.join(tempdir, ".git"), | ||
| 591 | objdir=os.path.join(tempdir, ".git"), | ||
| 592 | worktree=tempdir, | ||
| 593 | relpath="test-project", | ||
| 594 | revisionExpr="1234abcd", | ||
| 595 | revisionId=None, | ||
| 596 | sync_strategy="stateless", | ||
| 597 | ) | ||
| 598 | proj._CheckForImmutableRevision = mock.MagicMock(return_value=False) | ||
| 599 | proj._LsRemote = mock.MagicMock( | ||
| 600 | return_value="1234abcd\trefs/heads/main\n" | ||
| 601 | ) | ||
| 602 | proj.bare_git = mock.MagicMock() | ||
| 603 | proj.bare_git.rev_parse.return_value = "5678abcd" | ||
| 604 | proj.bare_git.rev_list.return_value = ["0"] | ||
| 605 | proj.IsDirty = mock.MagicMock(return_value=False) | ||
| 606 | proj.GetBranches = mock.MagicMock(return_value=[]) | ||
| 607 | proj.DeleteWorktree = mock.MagicMock() | ||
| 608 | proj._InitGitDir = mock.MagicMock() | ||
| 609 | proj._RemoteFetch = mock.MagicMock(return_value=True) | ||
| 610 | proj._InitRemote = mock.MagicMock() | ||
| 611 | proj._InitMRef = mock.MagicMock() | ||
| 612 | return proj | ||
| 613 | |||
| 614 | def test_sync_network_half_stateless_prune_needed(self): | ||
| 615 | """Test stateless sync queues prune when needed.""" | ||
| 616 | with utils_for_test.TempGitTree() as tempdir: | ||
| 617 | proj = self._get_project(tempdir) | ||
| 618 | res = proj.Sync_NetworkHalf() | ||
| 619 | |||
| 620 | self.assertTrue(res.success) | ||
| 621 | proj.DeleteWorktree.assert_not_called() | ||
| 622 | self.assertTrue(proj.stateless_prune_needed) | ||
| 623 | proj._RemoteFetch.assert_called_once() | ||
| 624 | |||
| 625 | def test_sync_local_half_stateless_prune(self): | ||
| 626 | """Test stateless GC pruning is queued in Sync_LocalHalf.""" | ||
| 627 | with utils_for_test.TempGitTree() as tempdir: | ||
| 628 | proj = self._get_project(tempdir) | ||
| 629 | proj.stateless_prune_needed = True | ||
| 630 | |||
| 631 | proj._Checkout = mock.MagicMock() | ||
| 632 | proj._InitWorkTree = mock.MagicMock() | ||
| 633 | proj.IsRebaseInProgress = mock.MagicMock(return_value=False) | ||
| 634 | proj.IsCherryPickInProgress = mock.MagicMock(return_value=False) | ||
| 635 | proj.bare_ref = mock.MagicMock() | ||
| 636 | proj.bare_ref.all = {} | ||
| 637 | proj.GetRevisionId = mock.MagicMock(return_value="1234abcd") | ||
| 638 | proj._CopyAndLinkFiles = mock.MagicMock() | ||
| 639 | |||
| 640 | proj.work_git = mock.MagicMock() | ||
| 641 | proj.work_git.GetHead.return_value = "5678abcd" | ||
| 642 | |||
| 643 | syncbuf = project.SyncBuffer(proj.config) | ||
| 644 | |||
| 645 | with mock.patch("project.GitCommand") as mock_git_cmd: | ||
| 646 | mock_cmd_instance = mock.MagicMock() | ||
| 647 | mock_cmd_instance.Wait.return_value = 0 | ||
| 648 | mock_git_cmd.return_value = mock_cmd_instance | ||
| 649 | |||
| 650 | proj.Sync_LocalHalf(syncbuf) | ||
| 651 | syncbuf.Finish() | ||
| 652 | |||
| 653 | self.assertEqual(mock_git_cmd.call_count, 2) | ||
| 654 | mock_git_cmd.assert_any_call( | ||
| 655 | proj, ["reflog", "expire", "--expire=all", "--all"], bare=True | ||
| 656 | ) | ||
| 657 | mock_git_cmd.assert_any_call( | ||
| 658 | proj, | ||
| 659 | ["gc", "--prune=now"], | ||
| 660 | bare=True, | ||
| 661 | capture_stdout=True, | ||
| 662 | capture_stderr=True, | ||
| 663 | ) | ||
| 664 | |||
| 665 | def test_sync_network_half_stateless_skips_if_stash(self): | ||
| 666 | """Test stateless sync skips if stash exists.""" | ||
| 667 | with utils_for_test.TempGitTree() as tempdir: | ||
| 668 | proj = self._get_project(tempdir) | ||
| 669 | proj.HasStash = mock.MagicMock(return_value=True) | ||
| 670 | |||
| 671 | res = proj.Sync_NetworkHalf() | ||
| 672 | |||
| 673 | self.assertTrue(res.success) | ||
| 674 | self.assertFalse(getattr(proj, "stateless_prune_needed", False)) | ||
| 675 | |||
| 676 | def test_sync_network_half_stateless_skips_if_local_commits(self): | ||
| 677 | """Test stateless sync skips if there are local-only commits.""" | ||
| 678 | with utils_for_test.TempGitTree() as tempdir: | ||
| 679 | proj = self._get_project(tempdir) | ||
| 680 | proj.bare_git.rev_list.return_value = ["1"] | ||
| 681 | |||
| 682 | res = proj.Sync_NetworkHalf() | ||
| 683 | |||
| 684 | self.assertTrue(res.success) | ||
| 685 | self.assertFalse(getattr(proj, "stateless_prune_needed", False)) | ||
