summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGavin Mak <gavinmak@google.com>2026-04-01 23:03:09 +0000
committerLUCI <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com>2026-04-09 12:09:40 -0700
commit3b0eebeccfd4447e4a50ae143f37d0f7817723be (patch)
tree5bffc4647d4f883eac0a514ef304b8f2fd75c32c
parent00991bfb42c4f3b0b7a50aa8f475ac1c8369924b (diff)
downloadgit-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.py101
-rw-r--r--tests/test_project.py118
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
21import tempfile 21import tempfile
22from typing import Optional 22from typing import Optional
23import unittest 23import unittest
24from unittest import mock
24 25
25import utils_for_test 26import 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
571class 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))