project: don't plan a stateless prune without a worktree

Skip stateless prune planning when a project's worktree directory
does not exist, preventing fetch failures on subsequent syncs.

Also run _LsRemote() bare so it does not depend on worktree
existence.

Bug: 565047698
Change-Id: I385cba4e06813d9113ec7ddc963e8499acffaec5
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/634426
Tested-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
diff --git a/project.py b/project.py
index 6fce0e4..a2786e4 100644
--- a/project.py
+++ b/project.py
@@ -1667,6 +1667,12 @@
         if not self.Exists:
             return False
 
+        # Local changes can't be checked without a worktree, and pruning drops
+        # every reflog. Sync_LocalHalf() recreates the worktree, so a later
+        # sync can decide.
+        if not self.worktree or not platform_utils.isdir(self.worktree):
+            return False
+
         if self._CheckForImmutableRevision(use_superproject=use_superproject):
             return False
 
@@ -3914,9 +3920,9 @@
                     f"{self.name} cherry-pick {rev} ", project=self.name
                 )
 
-    def _LsRemote(self, refs):
+    def _LsRemote(self, refs: str) -> Optional[str]:
         cmd = ["ls-remote", self.remote.name, refs]
-        p = GitCommand(self, cmd, capture_stdout=True)
+        p = GitCommand(self, cmd, bare=True, capture_stdout=True)
         if p.Wait() == 0:
             return p.stdout
         return None
diff --git a/tests/test_project.py b/tests/test_project.py
index 46bc7f1..249cbd0 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -2420,6 +2420,33 @@
             self.assertTrue(res.success)
             self.assertFalse(getattr(proj, "stateless_prune_needed", False))
 
+    def test_sync_network_half_stateless_skips_without_worktree(self) -> None:
+        """Test stateless sync doesn't prune a project with no worktree."""
+        with utils_for_test.TempGitTree() as tempdir:
+            proj = self._get_project(tempdir)
+            proj.worktree = os.path.join(tempdir, "missing")
+            proj._HasDirtyOrStash = mock.MagicMock(return_value=False)
+
+            res = proj.Sync_NetworkHalf()
+
+            self.assertTrue(res.success)
+            self.assertFalse(proj.stateless_prune_needed)
+            proj._LsRemote.assert_not_called()
+            proj._HasDirtyOrStash.assert_not_called()
+
+    def test_ls_remote_runs_without_worktree(self) -> None:
+        """Test ls-remote only needs the gitdir's remote config."""
+        with utils_for_test.TempGitTree() as tempdir:
+            proj = _create_mock_project(tempdir)
+            proj.work_git.commit("--allow-empty", "-m", "initial")
+            proj.work_git.config("remote.origin.url", tempdir)
+            head = proj.work_git.rev_parse("HEAD")
+            proj.worktree = os.path.join(tempdir, "missing")
+            # _create_mock_project() stubs this out.
+            del proj._LsRemote
+
+            self.assertEqual(f"{head}\tHEAD\n", proj._LsRemote("HEAD"))
+
     def test_sync_network_half_stateless_skips_if_local_commits(self):
         """Test stateless sync skips if there are local-only commits."""
         with utils_for_test.TempGitTree() as tempdir: