| # Copyright (C) 2026 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| """Unittests for the git_status.py module.""" |
| |
| import os |
| from typing import Any, List |
| from unittest import mock |
| |
| import pytest |
| |
| import git_status |
| |
| |
| def test_parse_porcelain_v2_branch_and_paths() -> None: |
| output = ( |
| b"# branch.oid " + b"1" * 40 + b"\0" |
| b"# branch.head topic\0" |
| b"# branch.upstream origin/main\0" |
| b"# branch.ab +2 -3\0" |
| b"# stash 1\0" |
| b"1 M. N... 100644 100644 100644 " |
| + b"1" * 40 |
| + b" " |
| + b"2" * 40 |
| + b" staged name\0" |
| b"1 .M N... 100644 100644 100644 " |
| + b"1" * 40 |
| + b" " |
| + b"2" * 40 |
| + b" worktree name\0" |
| b"2 R. N... 100644 100644 100644 " |
| + b"1" * 40 |
| + b" " |
| + b"2" * 40 |
| + b" R075 renamed\0old name\0" |
| b"? untracked\0" |
| ) |
| |
| status = git_status.ParsePorcelainV2(output) |
| |
| assert status.current_branch == "topic" |
| assert status.upstream == "origin/main" |
| assert (status.ahead, status.behind, status.stash_count) == (2, 3, 1) |
| assert status.index_changes["staged name"].status == "M" |
| assert status.worktree_changes["worktree name"].status == "M" |
| renamed = status.index_changes["renamed"] |
| assert (renamed.src_path, renamed.level) == ("old name", "75") |
| assert status.untracked == ["untracked"] |
| |
| |
| def test_parse_porcelain_v2_unmerged_and_non_utf8_path() -> None: |
| path = b"bad-\xff-name" |
| output = ( |
| b"u UU N... 100644 100644 100644 100644 " |
| + b"1" * 40 |
| + b" " |
| + b"2" * 40 |
| + b" " |
| + b"3" * 40 |
| + b" " |
| + path |
| + b"\0" |
| ) |
| |
| status = git_status.ParsePorcelainV2(output) |
| decoded = os.fsdecode(path) |
| |
| assert status.index_changes[decoded].status == "U" |
| assert status.worktree_changes[decoded].status == "U" |
| assert os.fsencode(status.index_changes[decoded].path) == path |
| |
| |
| def test_untracked_only_respects_consider_untracked() -> None: |
| status = git_status.ParsePorcelainV2(b"? new file\0") |
| |
| assert status.is_dirty() |
| assert not status.is_dirty(consider_untracked=False) |
| |
| |
| def test_branch_headers_preserve_non_ascii_names() -> None: |
| branch = "tópico" |
| status = git_status.ParsePorcelainV2( |
| b"# branch.oid " + b"1" * 40 + b"\0" |
| b"# branch.head " + os.fsencode(branch) + b"\0" |
| ) |
| |
| assert status.current_branch == branch |
| |
| |
| def test_quick_ahead_behind_is_recorded_as_unknown() -> None: |
| status = git_status.ParsePorcelainV2(b"# branch.ab +? -?\0") |
| |
| assert (status.ahead, status.behind) == (0, 0) |
| assert not status.has_ahead_behind |
| |
| |
| def test_unknown_head_is_not_a_current_branch() -> None: |
| status = git_status.ParsePorcelainV2(b"# branch.head (unknown)\0") |
| |
| assert status.current_branch is None |
| |
| |
| def test_malformed_output_is_rejected() -> None: |
| with pytest.raises(git_status.StatusParseError): |
| git_status.ParsePorcelainV2(b"2 R. truncated\0") |
| |
| |
| def test_malformed_branch_ab_is_rejected() -> None: |
| with pytest.raises(git_status.StatusParseError): |
| git_status.ParsePorcelainV2(b"# branch.ab not-a-valid-ab\0") |
| |
| |
| def test_ignored_records_are_skipped() -> None: |
| status = git_status.ParsePorcelainV2(b"! ignored_file\0") |
| assert not status.is_dirty() |
| assert status.untracked == [] |
| |
| |
| def test_get_status_uses_versioned_machine_options( |
| monkeypatch: pytest.MonkeyPatch, |
| ) -> None: |
| commands = [] |
| |
| class FakeGitCommand: |
| def __init__( |
| self, _project: Any, cmdv: List[str], **kwargs: Any |
| ) -> None: |
| commands.append((cmdv, kwargs)) |
| self.stdout = b"" |
| |
| def Wait(self) -> int: |
| return 0 |
| |
| monkeypatch.setattr(git_status, "GitCommand", FakeGitCommand) |
| monkeypatch.setattr(git_status, "git_require", lambda _version: True) |
| |
| git_status.GetStatus( |
| mock.sentinel.project, |
| mock.sentinel.gitdir, |
| untracked_files="no", |
| branch=True, |
| ahead_behind=True, |
| show_stash=True, |
| ) |
| |
| cmd, kwargs = commands[0] |
| assert cmd == [ |
| "status", |
| "--porcelain=v2", |
| "-z", |
| "--ignore-submodules=all", |
| "--untracked-files=no", |
| "--branch", |
| "--ahead-behind", |
| "--renames", |
| "--show-stash", |
| ] |
| assert kwargs["capture_stdout_bytes"] |
| |
| |
| def test_get_status_rejects_git_before_2_11( |
| monkeypatch: pytest.MonkeyPatch, |
| ) -> None: |
| monkeypatch.setattr(git_status, "git_require", lambda _version: False) |
| |
| with pytest.raises(git_status.UnsupportedStatusError): |
| git_status.GetStatus(mock.sentinel.project, mock.sentinel.gitdir) |
| |
| |
| def test_get_status_omits_stash_header_before_2_35( |
| monkeypatch: pytest.MonkeyPatch, |
| ) -> None: |
| commands = [] |
| |
| class FakeGitCommand: |
| def __init__( |
| self, _project: Any, cmdv: List[str], **_kwargs: Any |
| ) -> None: |
| commands.append(cmdv) |
| self.stdout = b"" |
| |
| def Wait(self) -> int: |
| return 0 |
| |
| monkeypatch.setattr(git_status, "GitCommand", FakeGitCommand) |
| monkeypatch.setattr( |
| git_status, |
| "git_require", |
| lambda version: version <= (2, 34, 0), |
| ) |
| |
| git_status.GetStatus( |
| mock.sentinel.project, |
| mock.sentinel.gitdir, |
| show_stash=True, |
| ) |
| |
| assert "--show-stash" not in commands[0] |