diff --git a/project.py b/project.py index a73c7d4fa..45a3aa865 100644 --- a/project.py +++ b/project.py @@ -4464,8 +4464,52 @@ class Project: return dotgit if subpath is None else os.path.join(dotgit, subpath) + @staticmethod + def _ParseHead(line: str) -> Optional[str]: + """Parse the content of a .git/HEAD file. + + Handles both symbolic refs (e.g. 'ref: refs/heads/...') and raw + commit IDs (40-hex SHA-1 or 64-hex SHA-256). + + Returns: + The ref name (e.g. 'refs/heads/main') or lowercase commit hash + if valid, or None if empty or invalid. + """ + line = line.strip() + if line.startswith("ref:"): + ref = line[4:].strip() + # Ensure the ref is not empty, pure whitespace, or the + # "refs/heads/.invalid" placeholder used for unborn branches, + # empty repositories, or when the reftables backend is used + # (which will be the default in Git 3.0). + if not ref or ref == R_HEADS + ".invalid": + return None + return ref + else: + # Normalize commit IDs to canonical lowercase hexadecimal, + # matching the output format of `git rev-parse`. + line_lower = line.lower() + if IsId(line_lower): + return line_lower + return None + def GetHead(self): """Return the ref that HEAD points to.""" + path = None + try: + # Catch AssertionError raised by GetDotgitPath when worktree + # .git pointer file is malformed (e.g. missing 'gitdir:'). + path = self.GetDotgitPath(subpath=HEAD) + if not platform_utils.islink(path): + with open( + path, "r", encoding="utf-8", errors="replace" + ) as fd: + ref = self._ParseHead(fd.readline()) + if ref: + return ref + except (OSError, AssertionError): + pass + try: return self.symbolic_ref("-q", HEAD, log_as_error=False) except GitError: @@ -4485,22 +4529,23 @@ class Project: # Fallback to direct file reading for compatibility with broken # repos, e.g. if HEAD points to an unborn branch. - path = self.GetDotgitPath(subpath=HEAD) + if not path: + raise NoManifestException( + self._project.RelPath(local=False), str(e) + ) try: - with open(path) as fd: - line = fd.readline() + with open( + path, "r", encoding="utf-8", errors="replace" + ) as fd: + ref = self._ParseHead(fd.readline()) except OSError: - raise NoManifestException(path, str(e)) - try: - line = line.decode() - except AttributeError: - pass - if line.startswith("ref: "): - ref = line[5:-1] - else: - ref = line[:-1] - if ref == R_HEADS + ".invalid": - raise NoManifestException(path, str(e)) + raise NoManifestException( + self._project.RelPath(local=False), str(e) + ) + if not ref: + raise NoManifestException( + self._project.RelPath(local=False), str(e) + ) return ref def SetHead(self, ref, message=None): diff --git a/tests/test_project.py b/tests/test_project.py index e716b1a73..cd184f84d 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -409,6 +409,228 @@ class ProjectTests(unittest.TestCase): ).strip() self.assertEqual(expected, fakeproj.work_git.GetHead()) + def test_parse_head(self) -> None: + """Verify _ParseHead parses refs, hashes, whitespace, and invalid + refs. + """ + with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir: + fakeproj = FakeProject(tempdir) + work_git = fakeproj.work_git + + # Standard symbolic ref + self.assertEqual( + work_git._ParseHead("ref: refs/heads/main\n"), + "refs/heads/main", + ) + + # Tabs and extra whitespace + self.assertEqual( + work_git._ParseHead("ref:\t refs/heads/branch \r\n"), + "refs/heads/branch", + ) + + # Reftables placeholder should return None + self.assertIsNone(work_git._ParseHead("ref: refs/heads/.invalid\n")) + + # Empty or whitespace-only symbolic refs should return None + self.assertIsNone(work_git._ParseHead("ref:\n")) + self.assertIsNone(work_git._ParseHead("ref: \t \r\n")) + + # 40-character SHA-1 + sha1 = "0123456789abcdef0123456789abcdef01234567" + self.assertEqual(work_git._ParseHead(f"{sha1}\n"), sha1) + + # Uppercase SHA-1 normalized to lowercase + sha_upper = "4B825DC642CB6EB9A060E54BF8D69288FBEE4904" + self.assertEqual( + work_git._ParseHead(f"{sha_upper}\r\n"), sha_upper.lower() + ) + + # 64-character SHA-256 + sha256 = "0123456789abcdef" * 4 + self.assertEqual(work_git._ParseHead(f"{sha256}\n"), sha256) + + # 40-character string with invalid hex characters (e.g. 'g'-'z') + invalid_sha = "0123456789abcdef0123456789abcdef0123456z" + self.assertIsNone(work_git._ParseHead(f"{invalid_sha}\n")) + + # Empty or unparseable lines + self.assertIsNone(work_git._ParseHead("")) + self.assertIsNone(work_git._ParseHead(" \n")) + self.assertIsNone(work_git._ParseHead("corrupted-not-a-hash")) + + def test_get_head_in_memory_fast_path(self) -> None: + """Verify GetHead reads HEAD in-memory without spawning subprocesses.""" + with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir: + fakeproj = FakeProject(tempdir) + os.makedirs(fakeproj.gitdir, exist_ok=True) + head_file = os.path.join(fakeproj.gitdir, "HEAD") + + # 1. Standard symbolic ref (on a branch) + with open(head_file, "w", encoding="utf-8", newline="") as fp: + fp.write("ref: refs/heads/feature-branch\n") + + with mock.patch.object( + fakeproj.work_git, "symbolic_ref" + ) as mock_sym, mock.patch.object( + fakeproj.work_git, "rev_parse" + ) as mock_parse: + self.assertEqual( + fakeproj.work_git.GetHead(), "refs/heads/feature-branch" + ) + mock_sym.assert_not_called() + mock_parse.assert_not_called() + + # 2. Whitespace, tabs, and CRLF handling + with open(head_file, "w", encoding="utf-8", newline="") as fp: + fp.write("ref:\t refs/heads/feature-branch \r\n") + + with mock.patch.object( + fakeproj.work_git, "symbolic_ref" + ) as mock_sym, mock.patch.object( + fakeproj.work_git, "rev_parse" + ) as mock_parse: + self.assertEqual( + fakeproj.work_git.GetHead(), "refs/heads/feature-branch" + ) + mock_sym.assert_not_called() + mock_parse.assert_not_called() + + # 3. Detached HEAD with 40-character SHA-1 + fake_sha1 = "0123456789abcdef0123456789abcdef01234567" + with open(head_file, "w", encoding="utf-8", newline="") as fp: + fp.write(f"{fake_sha1}\n") + + with mock.patch.object( + fakeproj.work_git, "symbolic_ref" + ) as mock_sym, mock.patch.object( + fakeproj.work_git, "rev_parse" + ) as mock_parse: + self.assertEqual(fakeproj.work_git.GetHead(), fake_sha1) + mock_sym.assert_not_called() + mock_parse.assert_not_called() + + # 4. Detached HEAD with 64-character SHA-256 + fake_sha256 = "0123456789abcdef" * 4 + with open(head_file, "w", encoding="utf-8", newline="") as fp: + fp.write(f"{fake_sha256}\n") + + with mock.patch.object( + fakeproj.work_git, "symbolic_ref" + ) as mock_sym, mock.patch.object( + fakeproj.work_git, "rev_parse" + ) as mock_parse: + self.assertEqual(fakeproj.work_git.GetHead(), fake_sha256) + mock_sym.assert_not_called() + mock_parse.assert_not_called() + + # 5. Uppercase SHA normalized to lowercase + fake_upper = fake_sha1.upper() + with open(head_file, "w", encoding="utf-8", newline="") as fp: + fp.write(f"{fake_upper}\n") + + with mock.patch.object( + fakeproj.work_git, "symbolic_ref" + ) as mock_sym, mock.patch.object( + fakeproj.work_git, "rev_parse" + ) as mock_parse: + self.assertEqual(fakeproj.work_git.GetHead(), fake_sha1) + mock_sym.assert_not_called() + mock_parse.assert_not_called() + + def test_get_head_symlink_and_fallback(self) -> None: + """Verify GetHead handles symlinks and invalid files via fallback.""" + with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir: + fakeproj = FakeProject(tempdir) + os.makedirs(fakeproj.gitdir, exist_ok=True) + head_file = os.path.join(fakeproj.gitdir, "HEAD") + + # Symlink HEAD should fall back to symbolic_ref + platform_utils.symlink("refs/heads/main", head_file) + with mock.patch.object( + fakeproj.work_git, + "symbolic_ref", + return_value="refs/heads/main", + ) as mock_sym: + self.assertEqual(fakeproj.work_git.GetHead(), "refs/heads/main") + mock_sym.assert_called_once() + + def test_get_head_worktree_corrupted_fallback(self) -> None: + """Verify GetHead raises NoManifestException on corrupted worktrees.""" + with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir: + dotgit = os.path.join(tempdir, ".git") + with open(dotgit, "w", encoding="utf-8", newline="") as fp: + fp.write("malformed without gitdir prefix\n") + fakeproj = FakeProject(tempdir) + with self.assertRaises(error.NoManifestException) as cm: + fakeproj.work_git.GetHead() + self.assertEqual(cm.exception.path, fakeproj.RelPath(local=False)) + + def test_get_head_fallback_robustness(self) -> None: + """Verify GetHead fallback handles CRLF, tabs, and lowercase hashes.""" + with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir: + fakeproj = FakeProject(tempdir) + os.makedirs(fakeproj.gitdir, exist_ok=True) + head_file = os.path.join(fakeproj.gitdir, "HEAD") + + # 1. Fallback strips tabs, extra whitespace, and CRLF + with open(head_file, "w", encoding="utf-8", newline="") as fp: + fp.write("ref:\t refs/heads/fallback-branch\r\n") + + with mock.patch("platform_utils.islink", return_value=True): + with mock.patch.object( + fakeproj.work_git, + "symbolic_ref", + side_effect=error.GitError("sym error"), + ), mock.patch.object( + fakeproj.work_git, + "rev_parse", + side_effect=error.GitError("parse error"), + ): + self.assertEqual( + fakeproj.work_git.GetHead(), + "refs/heads/fallback-branch", + ) + + # 2. Fallback normalizes uppercase hashes to lowercase + sha_upper = "4B825DC642CB6EB9A060E54BF8D69288FBEE4904" + with open(head_file, "w", encoding="utf-8", newline="") as fp: + fp.write(f"{sha_upper}\r\n") + + with mock.patch("platform_utils.islink", return_value=True): + with mock.patch.object( + fakeproj.work_git, + "symbolic_ref", + side_effect=error.GitError("sym error"), + ), mock.patch.object( + fakeproj.work_git, + "rev_parse", + side_effect=error.GitError("parse error"), + ): + self.assertEqual( + fakeproj.work_git.GetHead(), sha_upper.lower() + ) + + # 3. Fallback raises NoManifestException with RelPath on .invalid + with open(head_file, "w", encoding="utf-8", newline="") as fp: + fp.write("ref: refs/heads/.invalid\r\n") + + with mock.patch("platform_utils.islink", return_value=True): + with mock.patch.object( + fakeproj.work_git, + "symbolic_ref", + side_effect=error.GitError("sym error"), + ), mock.patch.object( + fakeproj.work_git, + "rev_parse", + side_effect=error.GitError("parse error"), + ): + with self.assertRaises(error.NoManifestException) as cm: + fakeproj.work_git.GetHead() + self.assertEqual( + cm.exception.path, fakeproj.RelPath(local=False) + ) + def _get_derived_subproject_url(self, submodule_url): with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir: