blob: d02dabf3b84e159cba41b78cfea32ed0c086ccd0 [file]
# Copyright (C) 2019 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 project.py module."""
import contextlib
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
import unittest
from unittest import mock
import pytest
import utils_for_test
import error
import git_command
import git_config
import git_status
import git_trace2_event_log
import manifest_xml
import platform_utils
import project
class FakeProject:
"""A fake for Project for basic functionality."""
def __init__(self, worktree):
self.worktree = worktree
self.gitdir = os.path.join(worktree, ".git")
self.name = "fakeproject"
self.work_git = project.Project._GitGetByExec(
self, bare=False, gitdir=self.gitdir
)
self.bare_git = project.Project._GitGetByExec(
self, bare=True, gitdir=self.gitdir
)
self.config = git_config.GitConfig.ForRepository(gitdir=self.gitdir)
def RelPath(self, local: Optional[bool] = None) -> str:
return self.name
class ReviewableBranchTests(unittest.TestCase):
"""Check ReviewableBranch behavior."""
def test_smoke(self):
"""A quick run through everything."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = FakeProject(tempdir)
# Generate some commits.
with open(os.path.join(tempdir, "readme"), "w") as fp:
fp.write("txt")
fakeproj.work_git.add("readme")
fakeproj.work_git.commit("-mAdd file")
fakeproj.work_git.checkout("-b", "work")
fakeproj.work_git.rm("-f", "readme")
fakeproj.work_git.commit("-mDel file")
# Start off with the normal details.
rb = project.ReviewableBranch(
fakeproj, fakeproj.config.GetBranch("work"), "main"
)
self.assertEqual("work", rb.name)
self.assertEqual(1, len(rb.commits))
self.assertIn("Del file", rb.commits[0])
d = rb.unabbrev_commits
self.assertEqual(1, len(d))
short, long = next(iter(d.items()))
self.assertTrue(long.startswith(short))
self.assertTrue(rb.base_exists)
self.assertEqual(["readme"], rb.modified_files)
# Hard to assert anything useful about this.
self.assertTrue(rb.date)
# Now delete the tracking branch!
fakeproj.work_git.branch("-D", "main")
rb = project.ReviewableBranch(
fakeproj, fakeproj.config.GetBranch("work"), "main"
)
self.assertEqual(0, len(rb.commits))
self.assertFalse(rb.base_exists)
# Hard to assert anything useful about this.
self.assertTrue(rb.date)
def test_upload_for_review_forwards_git_event_log(self) -> None:
"""Check UploadForReview passes git_event_log to project."""
proj = mock.MagicMock(spec=project.Project)
branch = mock.MagicMock()
branch.name = "work"
rb = project.ReviewableBranch(proj, branch, "main")
mock_event_log = mock.MagicMock()
rb.UploadForReview(people=([], []), git_event_log=mock_event_log)
proj.UploadForReview.assert_called_once()
_, kwargs = proj.UploadForReview.call_args
self.assertEqual(kwargs.get("git_event_log"), mock_event_log)
def test_current_comes_from_branch_snapshot(self) -> None:
"""Reviewable branches carry current state into report consumers."""
branch = mock.MagicMock()
branch.current = True
rb = project.ReviewableBranch(mock.MagicMock(), branch, "main")
self.assertTrue(rb.current)
class ProjectTests(unittest.TestCase):
"""Check Project behavior."""
def test_encode_patchset_description(self):
self.assertEqual(
project.Project._encode_patchset_description("abcd00!! +"),
"abcd00%21%21_%2b",
)
def test_find_gerrit_urls(self) -> None:
"""Check _FindGerritUrls extracts review URLs from stderr."""
# Single CL URL from standard push output.
stderr = (
"remote:\n"
"remote: Processing changes: new: 1, refs: 1, done\n"
"remote:\n"
"remote: SUCCESS\n"
"remote:\n"
"remote: https://gerrit.example.com/c/git-repo/+/616581"
" Add telemetry [NEW]\n"
"remote:\n"
"To sso://gerrit/git-repo\n"
" * [new reference] HEAD -> refs/for/main\n"
)
self.assertEqual(
project.Project._FindGerritUrls(stderr),
["https://gerrit.example.com/c/git-repo/+/616581"],
)
# Project names containing slashes and nested paths.
url1 = "https://example.com/c/platform/base/+/12345"
url2 = "https://example.com/c/vendor/device/raviole/prebuilts/+/987654"
stderr_nested = f"remote: {url1}\nremote: {url2} [NEW]\n"
self.assertEqual(
project.Project._FindGerritUrls(stderr_nested),
[url1, url2],
)
# Multiple URLs on same line or custom ports / http schemas.
stderr_custom = (
"remote: https://review.corp:8443/c/platform/manifest/+/4321\n"
"remote: http://localhost:8080/c/test-project/+/555\n"
)
self.assertEqual(
project.Project._FindGerritUrls(stderr_custom),
[
"https://review.corp:8443/c/platform/manifest/+/4321",
"http://localhost:8080/c/test-project/+/555",
],
)
# Non-matching outputs.
self.assertEqual(project.Project._FindGerritUrls(None), [])
self.assertEqual(project.Project._FindGerritUrls(""), [])
self.assertEqual(
project.Project._FindGerritUrls("Everything up-to-date\n"), []
)
self.assertEqual(
project.Project._FindGerritUrls(
"https://example.com/not/a/gerrit/url"
),
[],
)
def _create_project_for_upload_test(
self,
) -> Tuple[mock.MagicMock, mock.MagicMock]:
proj = mock.MagicMock(spec=project.Project)
proj.name = "test-project"
proj.UserEmail = "test@example.com"
proj.dest_branch = "refs/heads/main"
proj.bare_git = mock.MagicMock()
proj._FindGerritUrls = project.Project._FindGerritUrls
mock_branch = mock.MagicMock()
mock_branch.name = "test-branch"
mock_branch.LocalMerge = "refs/heads/main"
mock_branch.merge = "refs/heads/main"
mock_branch.remote.review = "http://review.example.com"
mock_branch.remote.name = "origin"
mock_branch.remote.projectname = "test-project"
mock_branch.remote.ReviewUrl.return_value = (
"https://review.example.com/test-project"
)
proj.GetBranch.return_value = mock_branch
return proj, mock_branch
def test_upload_for_review_event_emission(self) -> None:
"""Check UploadForReview emits repo.uploadstate trace2 data events."""
proj, _ = self._create_project_for_upload_test()
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
"project.ReviewableBranch"
) as mock_rb_cls:
mock_cmd = mock.MagicMock()
mock_cmd.Wait.return_value = 0
mock_cmd.stderr = (
"remote: https://example.com/c/test/+/123 [NEW]\n"
"remote: https://example.com/c/test/+/124 [NEW]\n"
)
mock_git_cmd.return_value = mock_cmd
mock_rb = mock.MagicMock()
mock_rb.modified_files = ["file1.txt", "file2.txt"]
mock_rb_cls.return_value = mock_rb
mock_event_log = mock.MagicMock()
project.Project.UploadForReview(
proj,
branch="test-branch",
dryrun=True,
git_event_log=mock_event_log,
)
mock_event_log.LogDataConfigEvents.assert_called_once_with(
{
"cls": (
"https://example.com/c/test/+/123,"
"https://example.com/c/test/+/124"
),
"remote": "origin",
"branch": "test-branch",
"files": "file1.txt,file2.txt",
},
"repo.uploadstate",
)
def test_upload_for_review_event_emission_no_cls(self) -> None:
"""Check event emission when stderr contains no review URLs."""
proj, _ = self._create_project_for_upload_test()
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
"project.ReviewableBranch"
) as mock_rb_cls:
mock_cmd = mock.MagicMock()
mock_cmd.Wait.return_value = 0
mock_cmd.stderr = "Everything up-to-date\n"
mock_git_cmd.return_value = mock_cmd
mock_rb = mock.MagicMock()
mock_rb.modified_files = ["dummy.txt"]
mock_rb_cls.return_value = mock_rb
mock_event_log = mock.MagicMock()
project.Project.UploadForReview(
proj,
branch="test-branch",
dryrun=True,
git_event_log=mock_event_log,
)
mock_event_log.LogDataConfigEvents.assert_called_once_with(
{
"cls": "",
"remote": "origin",
"branch": "test-branch",
"files": "dummy.txt",
},
"repo.uploadstate",
)
def test_upload_for_review_no_event_log(self) -> None:
"""Check UploadForReview succeeds when git_event_log is None."""
proj, _ = self._create_project_for_upload_test()
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
"project.ReviewableBranch"
) as mock_rb_cls:
mock_cmd = mock.MagicMock()
mock_cmd.Wait.return_value = 0
mock_cmd.stderr = "remote: https://example.com/c/test/+/1\n"
mock_git_cmd.return_value = mock_cmd
mock_rb = mock.MagicMock()
mock_rb.modified_files = ["file.txt"]
mock_rb_cls.return_value = mock_rb
project.Project.UploadForReview(
proj,
branch="test-branch",
dryrun=True,
git_event_log=None,
)
def test_upload_for_review_tracing_exception_handled(self) -> None:
"""Check exceptions during tracing are caught and do not fail upload."""
proj, _ = self._create_project_for_upload_test()
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
"project.ReviewableBranch"
) as mock_rb_cls, mock.patch("project.logger") as mock_logger:
mock_cmd = mock.MagicMock()
mock_cmd.Wait.return_value = 0
mock_cmd.stderr = "remote: https://example.com/c/test/+/1\n"
mock_git_cmd.return_value = mock_cmd
mock_rb_cls.side_effect = Exception("failed to inspect branch")
mock_event_log = mock.MagicMock()
project.Project.UploadForReview(
proj,
branch="test-branch",
dryrun=True,
git_event_log=mock_event_log,
)
mock_logger.error.assert_called_once()
mock_event_log.LogDataConfigEvents.assert_not_called()
def test_upload_for_review_real_event_log(self) -> None:
"""Check integration with real git_trace2_event_log.EventLog."""
proj, _ = self._create_project_for_upload_test()
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
"project.ReviewableBranch"
) as mock_rb_cls:
mock_cmd = mock.MagicMock()
mock_cmd.Wait.return_value = 0
mock_cmd.stderr = (
"remote: https://example.com/c/test/+/456 [NEW]\n"
)
mock_git_cmd.return_value = mock_cmd
mock_rb = mock.MagicMock()
mock_rb.modified_files = ["file1.py", "file2.py"]
mock_rb_cls.return_value = mock_rb
event_log = git_trace2_event_log.EventLog(env={})
project.Project.UploadForReview(
proj,
branch="test-branch",
dryrun=True,
git_event_log=event_log,
)
data_events = [
e for e in event_log._log if e.get("event") == "data"
]
data_map = {e["key"]: e["value"] for e in data_events}
self.assertEqual(
data_map.get("repo.uploadstate/cls"),
"https://example.com/c/test/+/456",
)
self.assertEqual(data_map.get("repo.uploadstate/remote"), "origin")
self.assertEqual(
data_map.get("repo.uploadstate/branch"), "test-branch"
)
self.assertEqual(
data_map.get("repo.uploadstate/files"), "file1.py,file2.py"
)
def test_get_head_revision_id(self):
"""Check GetHeadRevisionId behavior."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
# Initially unborn HEAD should return None.
self.assertIsNone(proj.GetHeadRevisionId())
# Create a commit.
with open(os.path.join(tempdir, "readme"), "w") as fp:
fp.write("hello")
proj.work_git.add("readme")
proj.work_git.commit("-m", "initial commit")
# HEAD should resolve to the commit SHA.
commit_sha = proj.work_git.rev_parse("HEAD")
self.assertEqual(commit_sha, proj.GetHeadRevisionId())
# Even if worktree is detached.
proj.work_git.checkout("HEAD~0")
self.assertEqual(commit_sha, proj.GetHeadRevisionId())
def test_resolve_commit_uses_end_of_options_when_supported(self) -> None:
"""Commit resolution separates user revisions from options."""
proj = mock.MagicMock(name="project")
proj.name = "project"
git = project.Project._GitGetByExec(proj, bare=True, gitdir="gitdir")
command = mock.MagicMock()
command.stdout = "1" * 40 + "\n"
command.Wait.return_value = 0
with mock.patch.object(
project, "git_require", return_value=True
), mock.patch.object(
project, "GitCommand", return_value=command
) as cmd:
self.assertEqual("1" * 40, git.ResolveCommit("topic"))
cmd.assert_called_once_with(
proj,
[
"rev-parse",
"--verify",
"--quiet",
"--end-of-options",
"topic^{commit}",
],
bare=True,
gitdir="gitdir",
capture_stdout=True,
capture_stderr=True,
verify_command=True,
log_as_error=False,
)
def test_resolve_commit_rejects_option_on_old_git(self) -> None:
"""Old Git never receives a revision that looks like an option."""
proj = mock.MagicMock(name="project")
proj.name = "project"
git = project.Project._GitGetByExec(proj, bare=True, gitdir="gitdir")
with mock.patch.object(project, "git_require", return_value=False):
with self.assertRaises(error.GitError):
git.ResolveCommit("--not-a-revision")
def test_get_branches_reuses_ref_snapshot_for_current_branch(self) -> None:
"""GetBranches derives the current branch from the loaded refs."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
proj.work_git = mock.MagicMock()
proj.bare_ref = mock.MagicMock()
proj.bare_ref.all = {
"HEAD": "1" * 40,
"refs/heads/topic": "1" * 40,
}
proj.bare_ref.head = "refs/heads/topic"
branches = proj.GetBranches()
self.assertTrue(branches["topic"].current)
proj.work_git.GetHead.assert_not_called()
def test_get_branches_reads_worktree_head_for_git_worktrees(self) -> None:
"""A shared repository HEAD is not a linked worktree's HEAD."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
proj.use_git_worktrees = True
proj.work_git = mock.MagicMock()
proj.work_git.GetHead.return_value = "refs/heads/worktree"
proj.bare_ref = mock.MagicMock()
proj.bare_ref.all = {
"HEAD": "1" * 40,
"refs/heads/common": "1" * 40,
"refs/heads/worktree": "1" * 40,
}
proj.bare_ref.head = "refs/heads/common"
branches = proj.GetBranches()
self.assertFalse(branches["common"].current)
self.assertTrue(branches["worktree"].current)
proj.work_git.GetHead.assert_called_once_with()
def test_get_branches_tolerates_unreadable_head(self) -> None:
"""An incomplete checkout still reports its local branches."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
proj.bare_ref = mock.MagicMock()
proj.bare_ref.all = {"refs/heads/topic": "1" * 40}
with mock.patch.object(
proj,
"_GetHead",
side_effect=error.NoManifestException("HEAD", "unreadable"),
):
branches = proj.GetBranches()
self.assertFalse(branches["topic"].current)
def test_get_head_returns_none_when_work_git_is_none(self) -> None:
"""Bare and mirror checkouts without work_git do not crash."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
proj.work_git = None
self.assertIsNone(proj._GetHead())
self.assertIsNone(proj.CurrentBranch)
def test_current_branch_returns_none_when_get_head_returns_none(
self,
) -> None:
"""CurrentBranch safely returns None when _GetHead returns None."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
with mock.patch.object(proj, "_GetHead", return_value=None):
self.assertIsNone(proj.CurrentBranch)
def test_prune_heads_reuses_refs_and_avoids_revision_walk(self) -> None:
"""Pruning uses snapshot OIDs to recognize an exact manifest HEAD."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
Path(tempdir, "tracked").write_text("initial")
proj.work_git.add("tracked")
proj.work_git.commit("-m", "initial")
revision = proj.work_git.rev_parse("HEAD")
proj.work_git.branch("merged")
proj.work_git.checkout("-b", "unmerged")
Path(tempdir, "tracked").write_text("topic")
proj.work_git.commit("-am", "topic")
proj.work_git.checkout("main")
proj.work_git.config("branch.unmerged.remote", ".")
proj.work_git.config("branch.unmerged.merge", "refs/heads/main")
proj.revisionId = revision
proj.bare_git = project.Project._GitGetByExec(
proj, bare=True, gitdir=proj.gitdir
)
proj._revlist = mock.MagicMock(
side_effect=AssertionError("unexpected revision walk")
)
kept = proj.PruneHeads()
self.assertEqual(["unmerged"], [branch.name for branch in kept])
self.assertEqual("", proj.bare_ref.get("refs/heads/merged"))
proj._revlist.assert_not_called()
def test_prune_heads_compares_peeled_tag_commit(self) -> None:
"""A branch at an annotated manifest tag can be pruned."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(
tempdir, revisionExpr="refs/tags/release"
)
Path(tempdir, "tracked").write_text("initial")
proj.work_git.add("tracked")
proj.work_git.commit("-m", "initial")
proj.work_git.tag("-a", "release", "-m", "release")
proj.bare_git = project.Project._GitGetByExec(
proj, bare=True, gitdir=proj.gitdir
)
kept = proj.PruneHeads()
self.assertEqual([], kept)
self.assertIsNone(proj.CurrentBranch)
self.assertEqual("", proj.bare_ref.get("refs/heads/main"))
def test_prune_heads_preserves_detached_head_when_current_branch_pruned(
self,
) -> None:
"""Pruning current branch keeps HEAD detached at target commit."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
Path(tempdir, "tracked").write_text("initial")
proj.work_git.add("tracked")
proj.work_git.commit("-m", "initial")
revision = proj.work_git.rev_parse("HEAD")
proj.revisionId = revision
proj.bare_git = project.Project._GitGetByExec(
proj, bare=True, gitdir=proj.gitdir
)
proj.bare_ref.head
kept = proj.PruneHeads()
self.assertEqual([], kept)
self.assertIsNone(proj.CurrentBranch)
self.assertEqual("", proj.bare_ref.get("refs/heads/main"))
self.assertEqual(revision, proj.bare_git.rev_parse("HEAD"))
def test_prune_heads_with_git_worktrees_preserves_bare_head(self) -> None:
"""Worktree pruning does not detach the shared bare repository HEAD."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
Path(tempdir, "tracked").write_text("initial")
proj.work_git.add("tracked")
proj.work_git.commit("-m", "initial")
revision = proj.work_git.rev_parse("HEAD")
proj.revisionId = revision
proj.use_git_worktrees = True
proj.bare_git = mock.MagicMock()
proj.bare_git.GetHead.return_value = "refs/heads/manifest"
kept = proj.PruneHeads()
self.assertEqual([], kept)
proj.bare_git.SetHead.assert_called_once_with("refs/heads/manifest")
proj.bare_git.DetachHead.assert_called_once_with(revision)
def test_dirty_status_snapshot(self) -> None:
"""Dirty checks cover staged, unstaged, and optional untracked files."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
Path(tempdir, "tracked").write_text("initial")
proj.work_git.add("tracked")
proj.work_git.commit("-m", "initial")
self.assertFalse(proj.IsDirty())
Path(tempdir, "tracked").write_text("staged")
proj.work_git.add("tracked")
self.assertTrue(proj.IsDirty(consider_untracked=False))
Path(tempdir, "tracked").write_text("unstaged")
self.assertEqual(
["tracked", "tracked"], proj.UncommittedFiles(get_all=True)
)
proj.work_git.reset("--hard", "HEAD")
Path(tempdir, "untracked").write_text("new")
self.assertTrue(proj.IsDirty())
self.assertFalse(proj.IsDirty(consider_untracked=False))
def test_uncommitted_files_preserve_staged_rename_paths(self) -> None:
"""The snapshot returns both paths reported by legacy diff-index."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
Path(tempdir, "old").write_text("tracked")
proj.work_git.add("old")
proj.work_git.commit("-m", "initial")
proj.work_git.mv("old", "new")
self.assertEqual(["new", "old"], proj.UncommittedFiles())
def test_quiet_divergent_status_uses_quick_snapshot(self) -> None:
"""Unknown quick counts do not force the legacy status pipeline."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
Path(tempdir, "base").write_text("base")
proj.work_git.add("base")
proj.work_git.commit("-m", "base")
proj.work_git.checkout("-b", "topic")
proj.work_git.config("branch.topic.remote", ".")
proj.work_git.config("branch.topic.merge", "refs/heads/main")
Path(tempdir, "topic").write_text("topic")
proj.work_git.add("topic")
proj.work_git.commit("-m", "topic")
proj.work_git.checkout("main")
Path(tempdir, "main").write_text("main")
proj.work_git.add("main")
proj.work_git.commit("-m", "main")
proj.work_git.checkout("topic")
proj._PrintWorkTreeStatusLegacy = mock.MagicMock(
side_effect=AssertionError("unexpected legacy status")
)
with tempfile.TemporaryFile(mode="w+") as output:
self.assertEqual(
"DIRTY",
proj.PrintWorkTreeStatus(output_redir=output, quiet=True),
)
proj._PrintWorkTreeStatusLegacy.assert_not_called()
def test_quiet_legacy_dirty_status_skips_branch_lookup(self) -> None:
"""Old Git keeps the dirty fast return ahead of branch resolution."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
proj.work_git = mock.MagicMock()
proj.work_git.DiffZ.side_effect = [
{"tracked": mock.sentinel.change},
{},
]
proj.work_git.LsOthers.return_value = []
with tempfile.TemporaryFile(mode="w+") as output:
self.assertEqual(
"DIRTY",
proj._PrintWorkTreeStatusLegacy(
output_redir=output, quiet=True
),
)
proj.work_git.GetHead.assert_not_called()
def test_has_changes_includes_rebase_from_status_snapshot(self) -> None:
"""HasChanges keeps treating an in-progress rebase as a change."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
status = git_status.StatusSnapshot()
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
proj.IsRebaseInProgress = mock.MagicMock(return_value=True)
self.assertTrue(proj.HasChanges())
self.assertEqual(
["rebase in progress"], proj.UncommittedFiles(get_all=False)
)
def test_get_status_snapshot_handles_unsupported_status_error(self) -> None:
"""UnsupportedStatusError triggers the legacy status fallback."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
with mock.patch.object(
git_status,
"GetStatus",
side_effect=git_status.UnsupportedStatusError,
):
self.assertIsNone(proj._GetStatusSnapshot())
def test_dirty_or_stash_uses_status_stash_header(self) -> None:
"""A normal stash is detected without a second Git process."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
status = git_status.StatusSnapshot()
status.stash_count = 1
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
proj.HasStash = mock.MagicMock()
with mock.patch.object(project, "git_require", return_value=True):
self.assertTrue(proj._HasDirtyOrStash())
proj.HasStash.assert_not_called()
def test_dirty_or_stash_clean_no_stash_on_git_2_35(self) -> None:
"""A clean tree requires no second Git process on Git 2.35+."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
status = git_status.StatusSnapshot()
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
proj.HasStash = mock.MagicMock()
with mock.patch.object(project, "git_require", return_value=True):
self.assertFalse(proj._HasDirtyOrStash())
proj.HasStash.assert_not_called()
def test_dirty_or_stash_before_2_35_checks_stash_ref(self) -> None:
"""Older porcelain v2 output is not assumed to contain stash data."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
status = git_status.StatusSnapshot()
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
proj.HasStash = mock.MagicMock(return_value=True)
with mock.patch.object(project, "git_require", return_value=False):
self.assertTrue(proj._HasDirtyOrStash())
proj._GetStatusSnapshot.assert_called_once_with(
untracked_files="normal", show_stash=False
)
proj.HasStash.assert_called_once_with()
def test_old_git_dirty_check_uses_legacy_plumbing(self) -> None:
"""Git clients before 2.11 retain the existing dirty-check path."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
proj.work_git = mock.MagicMock()
proj.work_git.DiffZ.side_effect = [{}, {"tracked": mock.sentinel}]
with mock.patch.object(project, "git_require", return_value=False):
self.assertTrue(proj.IsDirty())
proj.work_git.update_index.assert_called_once_with(
"-q",
"--unmerged",
"--ignore-missing",
"--refresh",
log_as_error=False,
)
self.assertEqual(2, proj.work_git.DiffZ.call_count)
def test_fast_forward_always_rejects_merge_commits(self) -> None:
"""The fast-forward helper always passes --ff-only to Git."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
with mock.patch.object(
project, "GitCommand", autospec=True
) as run_git:
run_git.return_value.Wait.return_value = 0
proj._FastForward("revision")
run_git.assert_called_once_with(
proj, ["merge", "--no-stat", "--ff-only", "-q", "revision"]
)
run_git.reset_mock()
proj._FastForward("revision", quiet=False)
run_git.assert_called_once_with(
proj, ["merge", "--no-stat", "--ff-only", "revision"]
)
run_git.return_value.Wait.return_value = 1
with self.assertRaises(error.GitError):
proj._FastForward("revision")
@unittest.skipUnless(
utils_for_test.supports_reftable(),
"git reftable support is required for this test",
)
def test_get_head_unborn_reftable(self):
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
subprocess.check_call(
[
"git",
"-c",
"init.defaultRefFormat=reftable",
"init",
"-q",
tempdir,
]
)
fakeproj = FakeProject(tempdir)
expected = subprocess.check_output(
["git", "-C", tempdir, "symbolic-ref", "-q", "HEAD"],
encoding="utf-8",
).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 test_get_uploadable_branches_pruned_when_no_configured_branches(
self,
) -> None:
"""Verify GetUploadableBranches returns [] without reading all refs when
no branches are configured in .git/config."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
proj = _create_mock_project(tempdir)
with mock.patch.object(
project.Project, "_allrefs", new_callable=mock.PropertyMock
) as mock_allrefs:
res = proj.GetUploadableBranches()
self.assertEqual(res, [])
mock_allrefs.assert_not_called()
def test_get_uploadable_branches_pruned_when_only_2part_branch_keys(
self,
) -> None:
"""Verify GetUploadableBranches returns [] without reading all refs when
config contains 2-part keys like branch.autosetupmerge."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
proj = _create_mock_project(tempdir)
with mock.patch.object(
proj.config, "GetSubSections", return_value={""}
), mock.patch.object(
project.Project, "_allrefs", new_callable=mock.PropertyMock
) as mock_allrefs:
res = proj.GetUploadableBranches()
self.assertEqual(res, [])
mock_allrefs.assert_not_called()
def test_get_uploadable_branches_selected_branch_pruned_when_not_configured(
self,
) -> None:
"""Verify GetUploadableBranches(selected_branch) returns [] without
reading all refs when selected branch has no LocalMerge."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
proj = _create_mock_project(tempdir)
fake_branch = mock.MagicMock()
fake_branch.LocalMerge = None
with mock.patch.object(
proj, "GetBranch", return_value=fake_branch
), mock.patch.object(
project.Project, "_allrefs", new_callable=mock.PropertyMock
) as mock_allrefs:
res = proj.GetUploadableBranches("nonexistent-branch")
self.assertEqual(res, [])
mock_allrefs.assert_not_called()
def test_get_uploadable_branches_selected_branch_already_published(
self,
) -> None:
"""Verify GetUploadableBranches(selected_branch) returns [] when the
branch tip matches refs/published/<branch>."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
proj = _create_mock_project(tempdir)
fake_branch = mock.MagicMock()
fake_branch.LocalMerge = "refs/remotes/origin/main"
fake_branch.name = "feature"
sha1 = "0123456789abcdef0123456789abcdef01234567"
def get_ref(name: str) -> str:
if name in ("refs/heads/feature", "refs/published/feature"):
return sha1
return ""
with mock.patch.object(
proj, "GetBranch", return_value=fake_branch
), mock.patch.object(
proj.bare_ref, "get", side_effect=get_ref
), mock.patch.object(
proj, "GetUploadableBranch"
) as mock_get_up:
res = proj.GetUploadableBranches("feature")
self.assertEqual(res, [])
mock_get_up.assert_not_called()
def test_get_uploadable_branches_selected_branch_uploadable(
self,
) -> None:
"""Verify GetUploadableBranches(selected_branch) returns the uploadable
branch when new commits exist."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
proj = _create_mock_project(tempdir)
fake_branch = mock.MagicMock()
fake_branch.LocalMerge = "refs/remotes/origin/main"
fake_branch.name = "feature"
head_sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
pub_sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
mock_rb = mock.MagicMock()
mock_rb.name = "feature"
def get_ref(name: str) -> str:
if name == "refs/heads/feature":
return head_sha
if name == "refs/published/feature":
return pub_sha
return ""
with mock.patch.object(
proj, "GetBranch", return_value=fake_branch
), mock.patch.object(
proj.bare_ref, "get", side_effect=get_ref
), mock.patch.object(
proj, "GetUploadableBranch", return_value=mock_rb
) as mock_get_up:
res = proj.GetUploadableBranches("feature")
self.assertEqual(res, [mock_rb])
mock_get_up.assert_called_once_with("feature")
def _get_derived_subproject_url(self, submodule_url):
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
class FakeManifest:
def __init__(self, topdir):
self.topdir = topdir
self.globalConfig = None
self.is_multimanifest = False
self.path_prefix = ""
self.paths = {}
def GetSubprojectName(self, parent, path):
return path
def GetSubprojectPaths(self, parent, name, path):
relpath = path
worktree = os.path.join(self.topdir, path)
gitdir = os.path.join(self.topdir, f"{path}.git")
objdir = os.path.join(self.topdir, f"{path}.obj")
os.makedirs(worktree, exist_ok=True)
os.makedirs(gitdir, exist_ok=True)
os.makedirs(objdir, exist_ok=True)
return relpath, worktree, gitdir, objdir
manifest = FakeManifest(tempdir)
worktree = os.path.join(tempdir, "parent")
gitdir = os.path.join(tempdir, "parent.git")
objdir = os.path.join(tempdir, "parent.obj")
os.makedirs(worktree)
os.makedirs(gitdir)
os.makedirs(objdir)
parent = project.Project(
manifest=manifest,
name="parent",
remote=project.RemoteSpec(
"origin", url="https://example.com/platform/superproject"
),
gitdir=gitdir,
objdir=objdir,
worktree=worktree,
relpath="parent",
revisionExpr="refs/heads/main",
revisionId=None,
)
def fake_get_submodules(current):
if current is parent:
return [("subrev", "child", submodule_url, "false")]
return []
with mock.patch.object(
project.Project, "_GetSubmodules", autospec=True
) as get_submodules:
get_submodules.side_effect = fake_get_submodules
result = parent.GetDerivedSubprojects()
self.assertEqual(1, len(result))
return result[0].remote.url
def test_derived_subproject_joins_only_git_relative_urls(self):
tests = (
(
"./submodule",
"https://example.com/platform/superproject/submodule",
),
("../sibling", "https://example.com/platform/sibling"),
)
for submodule_url, expected in tests:
with self.subTest(submodule_url=submodule_url):
self.assertEqual(
expected, self._get_derived_subproject_url(submodule_url)
)
def test_derived_subproject_leaves_dot_prefixed_names_unchanged(self):
for submodule_url in (".foo", "..bar"):
with self.subTest(submodule_url=submodule_url):
self.assertEqual(
submodule_url,
self._get_derived_subproject_url(submodule_url),
)
def test_set_revision_object_id_lengths(self) -> None:
"""SetRevision only treats exact 40- or 64-char hex as immutable IDs."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
# SHA-1 (40 hex chars) is recorded as revisionId directly.
sha1 = "a" * 40
proj.SetRevision(sha1)
self.assertEqual(proj.revisionId, sha1)
# SHA-256 (64 hex chars) is recorded as revisionId directly.
sha256 = "b" * 64
proj.SetRevision(sha256)
self.assertEqual(proj.revisionId, sha256)
# Intermediate hex strings (41-63 chars) must not be treated
# as commit IDs.
for length in (41, 48, 63):
proj.SetRevision("c" * length)
self.assertIsNone(proj.revisionId)
def test_remote_fetch_intermediate_hex_not_fetched_as_commit_id(
self,
) -> None:
"""41-char hex revisions are not fetched as raw commit IDs on shallow
fetch."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
proj.config.GetRemote("origin").ResetFetch()
hex41 = "a" * 41
proj.SetRevision(hex41)
with mock.patch("project.GitCommand") as mock_git:
mock_cmd = mock.MagicMock()
mock_cmd.Wait.return_value = 0
mock_git.return_value = mock_cmd
proj._RemoteFetch(depth=1, current_branch_only=True)
fetch_args = mock_git.call_args[0][1]
# When depth is set, commit IDs are passed directly to
# git fetch.
# Since 41 hex chars is not an ID, it must not appear as a
# standalone argument.
self.assertNotIn(hex41, fetch_args)
# Instead, it is treated as a branch ref and formatted
# as a refspec.
self.assertIn(
f"+refs/heads/{hex41}:refs/remotes/origin/{hex41}",
fetch_args,
)
class CopyLinkTestCase(unittest.TestCase):
"""TestCase for stub repo client checkouts.
It'll have a layout like this:
tempdir/ # self.tempdir
checkout/ # self.topdir
git-project/ # self.worktree
Attributes:
tempdir: A dedicated temporary directory.
worktree: The top of the repo client checkout.
topdir: The top of a project checkout.
"""
def setUp(self):
self.tempdirobj = tempfile.TemporaryDirectory(prefix="repo_tests")
self.tempdir = self.tempdirobj.name
self.topdir = os.path.join(self.tempdir, "checkout")
self.worktree = os.path.join(self.topdir, "git-project")
os.makedirs(self.topdir)
os.makedirs(self.worktree)
def tearDown(self):
self.tempdirobj.cleanup()
@staticmethod
def touch(path):
with open(path, "w"):
pass
def assertExists(self, path, msg=None):
"""Make sure |path| exists."""
if os.path.exists(path):
return
if msg is None:
msg = ["path is missing: %s" % path]
while path != "/":
path = os.path.dirname(path)
if not path:
# If we're given something like "foo", abort once we get to
# "".
break
result = os.path.exists(path)
msg.append(f"\tos.path.exists({path}): {result}")
if result:
msg.append("\tcontents: %r" % os.listdir(path))
break
msg = "\n".join(msg)
raise self.failureException(msg)
class CopyFile(CopyLinkTestCase):
"""Check _CopyFile handling."""
def CopyFile(self, src, dest):
return project._CopyFile(self.worktree, src, self.topdir, dest)
def test_basic(self):
"""Basic test of copying a file from a project to the toplevel."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
cf = self.CopyFile("foo.txt", "foo")
cf._Copy()
self.assertExists(os.path.join(self.topdir, "foo"))
def test_src_subdir(self):
"""Copy a file from a subdir of a project."""
src = os.path.join(self.worktree, "bar", "foo.txt")
os.makedirs(os.path.dirname(src))
self.touch(src)
cf = self.CopyFile("bar/foo.txt", "new.txt")
cf._Copy()
self.assertExists(os.path.join(self.topdir, "new.txt"))
def test_dest_subdir(self):
"""Copy a file to a subdir of a checkout."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
cf = self.CopyFile("foo.txt", "sub/dir/new.txt")
self.assertFalse(os.path.exists(os.path.join(self.topdir, "sub")))
cf._Copy()
self.assertExists(os.path.join(self.topdir, "sub", "dir", "new.txt"))
def test_update(self):
"""Make sure changed files get copied again."""
src = os.path.join(self.worktree, "foo.txt")
dest = os.path.join(self.topdir, "bar")
with open(src, "w") as f:
f.write("1st")
cf = self.CopyFile("foo.txt", "bar")
cf._Copy()
self.assertExists(dest)
with open(dest) as f:
self.assertEqual(f.read(), "1st")
with open(src, "w") as f:
f.write("2nd!")
cf._Copy()
with open(dest) as f:
self.assertEqual(f.read(), "2nd!")
def test_src_block_symlink(self):
"""Do not allow reading from a symlinked path."""
src = os.path.join(self.worktree, "foo.txt")
sym = os.path.join(self.worktree, "sym")
self.touch(src)
platform_utils.symlink("foo.txt", sym)
self.assertExists(sym)
cf = self.CopyFile("sym", "foo")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
def test_src_block_symlink_traversal(self):
"""Do not allow reading through a symlink dir."""
realfile = os.path.join(self.tempdir, "file.txt")
self.touch(realfile)
src = os.path.join(self.worktree, "bar", "file.txt")
platform_utils.symlink(self.tempdir, os.path.join(self.worktree, "bar"))
self.assertExists(src)
cf = self.CopyFile("bar/file.txt", "foo")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
def test_src_block_copy_from_dir(self):
"""Do not allow copying from a directory."""
src = os.path.join(self.worktree, "dir")
os.makedirs(src)
cf = self.CopyFile("dir", "foo")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
def test_dest_block_symlink(self):
"""Do not allow writing to a symlink."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
platform_utils.symlink("dest", os.path.join(self.topdir, "sym"))
cf = self.CopyFile("foo.txt", "sym")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
def test_dest_block_symlink_traversal(self):
"""Do not allow writing through a symlink dir."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
platform_utils.symlink(
tempfile.gettempdir(), os.path.join(self.topdir, "sym")
)
cf = self.CopyFile("foo.txt", "sym/foo.txt")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
def test_src_block_copy_to_dir(self):
"""Do not allow copying to a directory."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
os.makedirs(os.path.join(self.topdir, "dir"))
cf = self.CopyFile("foo.txt", "dir")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
class LinkFile(CopyLinkTestCase):
"""Check _LinkFile handling."""
def LinkFile(self, src, dest):
return project._LinkFile(self.worktree, src, self.topdir, dest)
def test_basic(self):
"""Basic test of linking a file from a project into the toplevel."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
lf = self.LinkFile("foo.txt", "foo")
lf._Link()
dest = os.path.join(self.topdir, "foo")
self.assertExists(dest)
self.assertTrue(os.path.islink(dest))
self.assertEqual(
os.path.join("git-project", "foo.txt"), os.readlink(dest)
)
def test_src_subdir(self):
"""Link to a file in a subdir of a project."""
src = os.path.join(self.worktree, "bar", "foo.txt")
os.makedirs(os.path.dirname(src))
self.touch(src)
lf = self.LinkFile("bar/foo.txt", "foo")
lf._Link()
self.assertExists(os.path.join(self.topdir, "foo"))
def test_src_self(self):
"""Link to the project itself."""
dest = os.path.join(self.topdir, "foo", "bar")
lf = self.LinkFile(".", "foo/bar")
lf._Link()
self.assertExists(dest)
self.assertEqual(os.path.join("..", "git-project"), os.readlink(dest))
def test_dest_subdir(self):
"""Link a file to a subdir of a checkout."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
lf = self.LinkFile("foo.txt", "sub/dir/foo/bar")
self.assertFalse(os.path.exists(os.path.join(self.topdir, "sub")))
lf._Link()
self.assertExists(os.path.join(self.topdir, "sub", "dir", "foo", "bar"))
def test_src_block_relative(self):
"""Do not allow relative symlinks."""
BAD_SOURCES = (
"./",
"..",
"../",
"foo/.",
"foo/./bar",
"foo/..",
"foo/../foo",
)
for src in BAD_SOURCES:
lf = self.LinkFile(src, "foo")
self.assertRaises(error.ManifestInvalidPathError, lf._Link)
def test_update(self):
"""Make sure changed targets get updated."""
dest = os.path.join(self.topdir, "sym")
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
lf = self.LinkFile("foo.txt", "sym")
lf._Link()
self.assertEqual(
os.path.join("git-project", "foo.txt"), os.readlink(dest)
)
# Point the symlink somewhere else.
os.unlink(dest)
platform_utils.symlink(self.tempdir, dest)
lf._Link()
self.assertEqual(
os.path.join("git-project", "foo.txt"), os.readlink(dest)
)
def test_replace_empty_dir_with_symlink(self):
"""A linkfile should replace an empty real directory at the dest path.
This is the common case: the old linkfiles inside the directory were
already cleaned up by UpdateCopyLinkfileList, leaving an empty parent
directory behind.
"""
src_dir = os.path.join(self.worktree, "dot-llms")
os.makedirs(src_dir)
dest = os.path.join(self.topdir, "mydir")
os.makedirs(dest)
lf = self.LinkFile("dot-llms", "mydir")
lf._Link()
self.assertTrue(os.path.islink(dest))
self.assertEqual(
os.path.join("git-project", "dot-llms"), os.readlink(dest)
)
def test_nonempty_dir_not_clobbered(self):
"""A linkfile must not delete a non-empty directory.
If the user created files in a directory that a new linkfile wants
to replace, __linkIt should fail safely rather than deleting content.
"""
src_dir = os.path.join(self.worktree, "dot-llms")
os.makedirs(src_dir)
dest = os.path.join(self.topdir, "mydir")
os.makedirs(dest)
user_file = os.path.join(dest, "user-notes.txt")
self.touch(user_file)
lf = self.LinkFile("dot-llms", "mydir")
lf._Link()
# The directory should NOT be replaced — user content is preserved.
self.assertFalse(os.path.islink(dest))
self.assertTrue(os.path.isdir(dest))
self.assertTrue(os.path.exists(user_file))
class MigrateWorkTreeTests(unittest.TestCase):
"""Check _MigrateOldWorkTreeGitDir handling."""
_SYMLINKS = {
# go/keep-sorted start
"config",
"description",
"hooks",
"info",
"logs",
"objects",
"packed-refs",
"refs",
"reftable",
"rr-cache",
"shallow",
"svn",
# go/keep-sorted end
}
_FILES = {
"COMMIT_EDITMSG",
"FETCH_HEAD",
"HEAD",
"index",
"ORIG_HEAD",
"unknown-file-should-be-migrated",
}
_CLEAN_FILES = {
"a-vim-temp-file~",
"#an-emacs-temp-file#",
}
@classmethod
@contextlib.contextmanager
def _simple_layout(cls):
"""Create a simple repo client checkout to test against."""
with tempfile.TemporaryDirectory() as tempdir:
tempdir = Path(tempdir)
gitdir = tempdir / ".repo/projects/src/test.git"
gitdir.mkdir(parents=True)
cmd = ["git", "init", "--bare", str(gitdir)]
subprocess.check_call(cmd)
dotgit = tempdir / "src/test/.git"
dotgit.mkdir(parents=True)
for name in cls._SYMLINKS:
(dotgit / name).symlink_to(
f"../../../.repo/projects/src/test.git/{name}"
)
for name in cls._FILES | cls._CLEAN_FILES:
(dotgit / name).write_text(name)
yield tempdir
def test_standard(self):
"""Migrate a standard checkout that we expect."""
with self._simple_layout() as tempdir:
dotgit = tempdir / "src/test/.git"
project.Project._MigrateOldWorkTreeGitDir(str(dotgit))
# Make sure the dir was transformed into a symlink.
self.assertTrue(dotgit.is_symlink())
self.assertEqual(
os.readlink(dotgit),
os.path.normpath("../../.repo/projects/src/test.git"),
)
# Make sure files were moved over.
gitdir = tempdir / ".repo/projects/src/test.git"
for name in self._FILES:
self.assertEqual(name, (gitdir / name).read_text())
# Make sure files were removed.
for name in self._CLEAN_FILES:
self.assertFalse((gitdir / name).exists())
def test_unknown(self):
"""A checkout with unknown files should abort."""
with self._simple_layout() as tempdir:
dotgit = tempdir / "src/test/.git"
(tempdir / ".repo/projects/src/test.git/random-file").write_text(
"one"
)
(dotgit / "random-file").write_text("two")
with self.assertRaises(error.GitError):
project.Project._MigrateOldWorkTreeGitDir(str(dotgit))
# Make sure no content was actually changed.
self.assertTrue(dotgit.is_dir())
for name in self._FILES:
self.assertTrue((dotgit / name).is_file())
for name in self._CLEAN_FILES:
self.assertTrue((dotgit / name).is_file())
for name in self._SYMLINKS:
self.assertTrue((dotgit / name).is_symlink())
def test_reftable_anchor_with_refs_dir(self):
"""Migrate when reftable/ and refs/ are directories."""
with self._simple_layout() as tempdir:
dotgit = tempdir / "src/test/.git"
(dotgit / "refs").unlink()
(dotgit / "refs").mkdir()
(dotgit / "refs" / "heads").write_text("dummy")
(dotgit / "reftable").unlink()
(dotgit / "reftable").mkdir()
(dotgit / "reftable" / "tables.list").write_text("dummy")
project.Project._MigrateOldWorkTreeGitDir(str(dotgit))
self.assertTrue(dotgit.is_symlink())
self.assertEqual(
os.readlink(dotgit),
os.path.normpath("../../.repo/projects/src/test.git"),
)
class ManifestPropertiesFetchedCorrectly(unittest.TestCase):
"""Ensure properties are fetched properly."""
def setUpManifest(self, tempdir):
repodir = os.path.join(tempdir, ".repo")
manifest_dir = os.path.join(repodir, "manifests")
manifest_file = os.path.join(repodir, manifest_xml.MANIFEST_FILE_NAME)
os.mkdir(repodir)
os.mkdir(manifest_dir)
manifest = manifest_xml.XmlManifest(repodir, manifest_file)
return project.ManifestProject(
manifest, "test/manifest", os.path.join(tempdir, ".git"), tempdir
)
def test_manifest_config_properties(self):
"""Test we are fetching the manifest config properties correctly."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = self.setUpManifest(tempdir)
# Set property using the expected Set method, then ensure
# the porperty functions are using the correct Get methods.
fakeproj.config.SetString(
"manifest.standalone", "https://chicken/manifest.git"
)
self.assertEqual(
fakeproj.standalone_manifest_url, "https://chicken/manifest.git"
)
fakeproj.config.SetString(
"manifest.groups", "test-group, admin-group"
)
self.assertEqual(
fakeproj.manifest_groups, "test-group, admin-group"
)
fakeproj.config.SetString("repo.reference", "mirror/ref")
self.assertEqual(fakeproj.reference, "mirror/ref")
fakeproj.config.SetBoolean("repo.dissociate", False)
self.assertFalse(fakeproj.dissociate)
fakeproj.config.SetBoolean("repo.archive", False)
self.assertFalse(fakeproj.archive)
fakeproj.config.SetBoolean("repo.mirror", False)
self.assertFalse(fakeproj.mirror)
fakeproj.config.SetBoolean("repo.worktree", False)
self.assertFalse(fakeproj.use_worktree)
fakeproj.config.SetBoolean("repo.uselocalgitdirs", False)
self.assertFalse(fakeproj.use_local_gitdirs)
fakeproj.config.SetBoolean("repo.clonebundle", False)
self.assertFalse(fakeproj.clone_bundle)
fakeproj.config.SetBoolean("repo.submodules", False)
self.assertFalse(fakeproj.submodules)
fakeproj.config.SetBoolean("repo.git-lfs", False)
self.assertFalse(fakeproj.git_lfs)
fakeproj.config.SetBoolean("repo.superproject", False)
self.assertFalse(fakeproj.use_superproject)
fakeproj.config.SetBoolean("repo.partialclone", False)
self.assertFalse(fakeproj.partial_clone)
fakeproj.config.SetString("repo.depth", "48")
self.assertEqual(fakeproj.depth, 48)
fakeproj.config.SetString("repo.depth", "invalid_depth")
self.assertEqual(fakeproj.depth, None)
fakeproj.config.SetString("repo.clonefilter", "blob:limit=10M")
self.assertEqual(fakeproj.clone_filter, "blob:limit=10M")
fakeproj.config.SetString(
"repo.partialcloneexclude", "third_party/big_repo"
)
self.assertEqual(
fakeproj.partial_clone_exclude, "third_party/big_repo"
)
fakeproj.config.SetString("manifest.platform", "auto")
self.assertEqual(fakeproj.manifest_platform, "auto")
def test_check_immutable_revision_metaproject_skips_manifest_load(self):
"""MetaProjects must not parse manifest.xml during immutable check.
During `repo init` the manifestProject's own Sync_NetworkHalf runs
before manifest.xml has been linked into .repo/, so
_CheckForImmutableRevision must not touch it.
"""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = self.setUpManifest(tempdir)
manifest_path = os.path.join(
tempdir, ".repo", manifest_xml.MANIFEST_FILE_NAME
)
self.assertFalse(os.path.exists(manifest_path))
fakeproj.revisionExpr = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
fakeproj.upstream = "refs/heads/main"
# Must return False without raising ManifestParseError, and
# must leave the absent manifest.xml untouched.
self.assertFalse(
fakeproj._CheckForImmutableRevision(use_superproject=None)
)
self.assertFalse(os.path.exists(manifest_path))
def test_get_upstream_fallback_metaproject_skips_manifest_load(
self,
) -> None:
"""MetaProjects must not parse manifest.xml during upstream fallback."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = self.setUpManifest(tempdir)
manifest_path = os.path.join(
tempdir, ".repo", manifest_xml.MANIFEST_FILE_NAME
)
self.assertFalse(os.path.exists(manifest_path))
self.assertIsNone(fakeproj._GetUpstreamFallback())
self.assertFalse(os.path.exists(manifest_path))
def test_sharing_project_has_shallow_metaproject_skips_manifest_load(
self,
) -> None:
"""MetaProjects must not parse manifest.xml during sharing shallow
check."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = self.setUpManifest(tempdir)
manifest_path = os.path.join(
tempdir, ".repo", manifest_xml.MANIFEST_FILE_NAME
)
self.assertFalse(os.path.exists(manifest_path))
self.assertFalse(fakeproj._SharingProjectHasShallow())
self.assertFalse(os.path.exists(manifest_path))
def test_should_verify_upstream_metaproject_returns_false(
self,
) -> None:
"""MetaProjects never verify upstream ancestry."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = self.setUpManifest(tempdir)
fakeproj.revisionExpr = "4f8a3c0000000000000000000000000000000000"
fakeproj.upstream = "refs/heads/main"
self.assertFalse(
fakeproj._ShouldVerifyUpstream(use_superproject=False)
)
def test_sync_use_local_gitdirs_worktree_conflict(self):
"""Test that --use-local-gitdirs conflicts with --worktree."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = self.setUpManifest(tempdir)
class DummyManifest:
is_submanifest = False
def GetDefaultGroupsStr(self, with_platform=False):
return ""
fakeproj.manifest = DummyManifest()
result = fakeproj.Sync(use_local_gitdirs=True, worktree=True)
self.assertFalse(result)
def test_sync_use_local_gitdirs_archive_conflict(self):
"""Test that --use-local-gitdirs conflicts with --archive."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = self.setUpManifest(tempdir)
class DummyManifest:
is_submanifest = False
def GetDefaultGroupsStr(self, with_platform=False):
return ""
fakeproj.manifest = DummyManifest()
result = fakeproj.Sync(use_local_gitdirs=True, archive=True)
self.assertFalse(result)
def test_sync_use_local_gitdirs_mirror_conflict(self):
"""Test that --use-local-gitdirs conflicts with --mirror."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = self.setUpManifest(tempdir)
class DummyManifest:
is_submanifest = False
def GetDefaultGroupsStr(self, with_platform=False):
return ""
fakeproj.manifest = DummyManifest()
result = fakeproj.Sync(use_local_gitdirs=True, mirror=True)
self.assertFalse(result)
def test_delete_worktree_corrupted(self):
"""Test DeleteWorktree gracefully handles corrupted projects."""
for use_git_worktrees in (False, True):
with self.subTest(use_git_worktrees=use_git_worktrees):
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
os.makedirs(os.path.join(tempdir, "worktree"))
os.makedirs(os.path.join(tempdir, "gitdir"))
proj.worktree = os.path.join(tempdir, "worktree")
proj.gitdir = os.path.join(tempdir, "gitdir")
proj.use_git_worktrees = use_git_worktrees
with mock.patch.object(
proj,
"IsDirty",
side_effect=error.GitError("mock error"),
):
with self.assertRaises(project.DeleteWorktreeError):
proj.DeleteWorktree(force=False)
self.assertTrue(proj.DeleteWorktree(force=True))
self.assertFalse(os.path.exists(proj.worktree))
self.assertFalse(os.path.exists(proj.gitdir))
_VAR_CMD: List[str] = ["var", "GIT_DEFAULT_BRANCH"]
_CONFIG_CMD: List[str] = ["config", "--get", "init.defaultBranch"]
@pytest.mark.parametrize(
"supports_git_var, responses, expected_ref, expected_calls",
(
# git >= 2.35 answers `git var GIT_DEFAULT_BRANCH`.
(
True,
{"var": (0, "jellybean\n")},
"refs/heads/jellybean",
[_VAR_CMD],
),
# Older git: `git var` fails, so read init.defaultBranch instead.
(
False,
{"config": (0, "custom\n")},
"refs/heads/custom",
[_CONFIG_CMD],
),
# Nothing configured anywhere: git's historical built-in default.
(
False,
{"config": (1, "")},
"refs/heads/master",
[_CONFIG_CMD],
),
),
ids=("git_var", "old_git_reads_config", "unconfigured_defaults_to_master"),
)
def test_default_branch_fallback(
supports_git_var: bool,
responses: Dict[str, Tuple[int, str]],
expected_ref: str,
expected_calls: List[List[str]],
) -> None:
"""_DefaultBranchFallback resolves the default branch via git."""
seen: List[List[str]] = []
class FakeGitCommand:
# Emulate git by returning the canned response for the subcommand.
def __init__(
self, project_: Optional[project.Project], cmdv: List[str], **kwargs
) -> None:
self.returncode, self.stdout = responses[cmdv[0]]
seen.append(cmdv)
def Wait(self) -> int:
return self.returncode
# The result is memoized, so clear it before (to bypass any cached real
# value) and after (so the mocked value doesn't leak to other tests).
project._DefaultBranchFallback.cache_clear()
try:
with mock.patch.object(
project, "git_require", return_value=supports_git_var
), mock.patch.object(project, "GitCommand", FakeGitCommand):
assert project._DefaultBranchFallback() == expected_ref
assert seen == expected_calls
finally:
project._DefaultBranchFallback.cache_clear()
def test_metaproject_has_changes_bounds_revision_walk() -> None:
"""MetaProject only asks whether one remote-only commit exists."""
meta = mock.MagicMock()
meta.remote = mock.sentinel.remote
meta.revisionExpr = "refs/remotes/origin/main"
meta.bare_ref.all = {"HEAD": "local"}
meta.GetRevisionId.return_value = "remote"
meta._GetHead.return_value = "local"
meta._revlist.return_value = ["remote"]
assert project.MetaProject.HasChanges.fget(meta)
meta._revlist.assert_called_once_with("-1", "^HEAD", "remote")
@pytest.mark.parametrize(
"state,expect_failure",
[
("clean", False),
# -q makes git exit 0 here rather than 1, so the repeat is never
# reached. That is the point of it: an ordinary modified tree must
# not be reported as a failure.
("modified", False),
("locked", True),
],
)
def test_refresh_index_stat_cache(state: str, expect_failure: bool) -> None:
"""Refreshing tolerates local changes but reports a real failure."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
readme = Path(tempdir) / "readme"
readme.write_text("hello")
proj.work_git.add("readme")
proj.work_git.commit("-m", "initial commit")
if state == "modified":
readme.write_text("different contents")
elif state == "locked":
# git only needs the lock when the index has to be rewritten, so
# age the cached stat data while leaving the contents alone.
os.utime(readme, (1, 1))
(Path(proj.gitdir) / "index.lock").write_text("")
if not expect_failure:
proj._RefreshIndexStatCache()
return
with pytest.raises(error.GitError) as excinfo:
proj._RefreshIndexStatCache()
# Omitting -q is what lets git name the file it could not create.
assert "index.lock" in str(excinfo.value)
@pytest.mark.parametrize(
"repeat_rc,expect_raise",
[
# Whatever blocked the quiet attempt cleared in between, leaving the
# repeat to report nothing worse than uncommitted changes.
(1, False),
(128, True),
],
)
def test_refresh_index_stat_cache_repeat(
repeat_rc: int, expect_raise: bool
) -> None:
"""The repeat re-raises unless it failed with an ordinary exit 1."""
proj = mock.MagicMock()
proj.work_git.update_index.side_effect = [
git_command.GitCommandError("quiet attempt failed", git_rc=128),
git_command.GitCommandError("repeat failed", git_rc=repeat_rc),
]
if expect_raise:
with pytest.raises(git_command.GitCommandError):
project.Project._RefreshIndexStatCache(proj)
else:
project.Project._RefreshIndexStatCache(proj)
# The quiet attempt ran, then the repeat that explains it.
assert proj.work_git.update_index.call_count == 2
def _create_mock_project(
tempdir,
use_local_gitdirs=False,
fetch_cmd=None,
reproject_cmd: Optional[str] = None,
depth=None,
gitdir=None,
objdir=None,
revisionExpr="main",
sync_strategy=None,
):
manifest = mock.MagicMock()
manifest.manifestProject.use_local_gitdirs = use_local_gitdirs
manifest.manifestProject.fetch_cmd = fetch_cmd
manifest.manifestProject.reproject_cmd = reproject_cmd
manifest.manifestProject.depth = depth
manifest.manifestProject.dissociate = False
manifest.manifestProject.clone_filter = None
manifest.manifestProject.config.GetBoolean.return_value = False
manifest.is_multimanifest = False
manifest.IsMirror = False
manifest.topdir = tempdir
remote = mock.MagicMock()
remote.name = "origin"
remote.url = "http://example.com/repo"
if gitdir is None:
gitdir = os.path.join(tempdir, ".git")
if objdir is None:
objdir = os.path.join(tempdir, ".git")
proj = project.Project(
manifest=manifest,
name="test-project",
remote=remote,
gitdir=gitdir,
objdir=objdir,
worktree=tempdir,
relpath="test-project",
revisionExpr=revisionExpr,
revisionId=None,
sync_strategy=sync_strategy,
)
proj.bare_git = mock.MagicMock()
proj._LsRemote = mock.MagicMock(return_value="1234abcd\trefs/heads/main\n")
manifest.GetProjectsWithName.return_value = [proj]
return proj
class GetSubmoduleRevisions(unittest.TestCase):
"""Tests for Project.GetSubmoduleRevisions."""
def test_gitlinks_are_keyed_by_their_path(self) -> None:
with utils_for_test.TempGitTree() as tempdir:
parent = _create_mock_project(tempdir)
parent.GetRevisionId = mock.MagicMock(return_value="cafe1234")
parent._GetSubmodules = mock.MagicMock(
return_value=[("1234abcd", "sub", "http://example.com/sub", "")]
)
self.assertEqual(
parent.GetSubmoduleRevisions(), {"sub": "1234abcd"}
)
def test_none_when_the_revision_is_not_fetched(self) -> None:
with utils_for_test.TempGitTree() as tempdir:
parent = _create_mock_project(tempdir)
parent.GetRevisionId = mock.MagicMock(return_value="cafe1234")
parent.bare_git.rev_list = mock.MagicMock(
side_effect=error.GitError("missing object")
)
parent._GetSubmodules = mock.MagicMock()
self.assertIsNone(parent.GetSubmoduleRevisions())
parent._GetSubmodules.assert_not_called()
def test_none_without_a_revision(self) -> None:
with utils_for_test.TempGitTree() as tempdir:
parent = _create_mock_project(tempdir)
parent.GetRevisionId = mock.MagicMock(
side_effect=error.ManifestInvalidRevisionError("no revision")
)
parent._GetSubmodules = mock.MagicMock()
self.assertIsNone(parent.GetSubmoduleRevisions())
parent._GetSubmodules.assert_not_called()
class StatelessSyncTests(unittest.TestCase):
"""Tests for stateless sync strategy."""
def _get_project(self, tempdir):
proj = _create_mock_project(
tempdir, revisionExpr="1234abcd", sync_strategy="stateless"
)
proj._CheckForImmutableRevision = mock.MagicMock(return_value=False)
proj.bare_git.rev_parse.return_value = "5678abcd"
proj.bare_git.rev_list.return_value = ["0"]
proj.IsDirty = mock.MagicMock(return_value=False)
proj.GetBranches = mock.MagicMock(return_value=[])
proj.DeleteWorktree = mock.MagicMock()
proj._InitGitDir = mock.MagicMock()
proj._RemoteFetch = mock.MagicMock(return_value=True)
proj._InitRemote = mock.MagicMock()
proj._InitMRef = mock.MagicMock()
return proj
def test_sync_network_half_stateless_prune_needed(self):
"""Test stateless sync queues prune when needed."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
res = proj.Sync_NetworkHalf()
self.assertTrue(res.success)
proj.DeleteWorktree.assert_not_called()
self.assertTrue(proj.stateless_prune_needed)
proj._RemoteFetch.assert_called_once()
def test_sync_local_half_stateless_prune(self):
"""Test stateless GC pruning is queued in Sync_LocalHalf."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.stateless_prune_needed = True
proj._Checkout = mock.MagicMock()
proj._InitWorkTree = mock.MagicMock()
proj.IsRebaseInProgress = mock.MagicMock(return_value=False)
proj.IsCherryPickInProgress = mock.MagicMock(return_value=False)
proj.bare_ref = mock.MagicMock()
proj.bare_ref.all = {}
proj.bare_ref.head = "5678abcd"
proj.GetRevisionId = mock.MagicMock(return_value="1234abcd")
proj._CopyAndLinkFiles = mock.MagicMock()
proj.work_git = mock.MagicMock()
syncbuf = project.SyncBuffer(proj.config)
with mock.patch("project.GitCommand") as mock_git_cmd:
mock_cmd_instance = mock.MagicMock()
mock_cmd_instance.Wait.return_value = 0
mock_git_cmd.return_value = mock_cmd_instance
proj.Sync_LocalHalf(syncbuf)
syncbuf.Finish()
self.assertEqual(mock_git_cmd.call_count, 2)
mock_git_cmd.assert_any_call(
proj, ["reflog", "expire", "--expire=all", "--all"], bare=True
)
mock_git_cmd.assert_any_call(
proj,
["gc", "--prune=now"],
bare=True,
capture_stdout=True,
capture_stderr=True,
)
def test_sync_local_half_no_upstream_propagates_force_checkout(self):
"""Test Sync_LocalHalf forwards force_checkout when detaching."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj._InitWorkTree = mock.MagicMock()
proj.CleanPublishedCache = mock.MagicMock()
proj.GetRevisionId = mock.MagicMock(return_value="1234abcd")
proj._Checkout = mock.MagicMock()
proj._CopyAndLinkFiles = mock.MagicMock()
proj.work_git = mock.MagicMock()
proj.work_git.GetHead.return_value = "refs/heads/topic"
proj.bare_ref = mock.MagicMock()
proj.bare_ref.all = {"refs/heads/topic": "5678abcd"}
branch = mock.MagicMock()
branch.name = "topic"
branch.LocalMerge = False
proj.GetBranch = mock.MagicMock(return_value=branch)
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf, force_checkout=True)
proj._Checkout.assert_called_once_with(
"1234abcd", force_checkout=True, quiet=True
)
proj._CopyAndLinkFiles.assert_called_once_with()
def test_sync_local_half_skips_unused_detached_history_walk(self) -> None:
"""Non-verbose detached sync does not enumerate discarded commits."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj._InitWorkTree = mock.MagicMock()
proj.CleanPublishedCache = mock.MagicMock()
proj.GetRevisionId = mock.MagicMock(return_value="new")
proj._Checkout = mock.MagicMock()
proj._CopyAndLinkFiles = mock.MagicMock()
proj.IsRebaseInProgress = mock.MagicMock(return_value=False)
proj.IsCherryPickInProgress = mock.MagicMock(return_value=False)
proj._revlist = mock.MagicMock()
proj.bare_ref = mock.MagicMock()
proj.bare_ref.all = {"HEAD": "old"}
proj.bare_ref.head = "old"
proj.work_git = mock.MagicMock()
proj.work_git.GetHead.return_value = "old"
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf, verbose=False)
proj._revlist.assert_not_called()
proj._Checkout.assert_called_once_with(
"new", force_checkout=False, quiet=True
)
def test_sync_local_half_verbose_detached_uses_count(self) -> None:
"""Verbose detached sync queries commit count with --count."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj._InitWorkTree = mock.MagicMock()
proj.CleanPublishedCache = mock.MagicMock()
proj.GetRevisionId = mock.MagicMock(return_value="new")
proj._Checkout = mock.MagicMock()
proj._CopyAndLinkFiles = mock.MagicMock()
proj.IsRebaseInProgress = mock.MagicMock(return_value=False)
proj.IsCherryPickInProgress = mock.MagicMock(return_value=False)
proj._revlist = mock.MagicMock(return_value=["3"])
proj.bare_ref = mock.MagicMock()
proj.bare_ref.all = {"HEAD": "old"}
proj.bare_ref.head = "old"
proj.work_git = mock.MagicMock()
proj.work_git.GetHead.return_value = "old"
syncbuf = mock.MagicMock()
syncbuf.detach_head = False
proj.Sync_LocalHalf(syncbuf, verbose=True)
proj._revlist.assert_called_once_with("--count", "^new", "HEAD")
syncbuf.info.assert_called_once_with(
proj, "discarding %d commits", 3
)
def test_sync_local_half_uses_count_and_limit_for_published_branch(
self,
) -> None:
"""Published sync uses --count for gain and -1 for probe."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj._InitWorkTree = mock.MagicMock()
proj.CleanPublishedCache = mock.MagicMock()
proj.GetRevisionId = mock.MagicMock(return_value="new")
proj._CopyAndLinkFiles = mock.MagicMock()
proj.IsRebaseInProgress = mock.MagicMock(return_value=False)
proj.IsCherryPickInProgress = mock.MagicMock(return_value=False)
proj._revlist = mock.MagicMock(side_effect=[["2"], ["pub-sha"]])
proj.bare_ref = mock.MagicMock()
proj.bare_ref.all = {"refs/heads/topic": "old"}
proj.bare_ref.head = "refs/heads/topic"
proj.work_git = mock.MagicMock()
proj.work_git.GetHead.return_value = "refs/heads/topic"
proj.work_git.merge_base.side_effect = project.GitError("diverged")
branch = mock.MagicMock()
branch.name = "topic"
branch.LocalMerge = "refs/remotes/origin/main"
proj.GetBranch = mock.MagicMock(return_value=branch)
proj.WasPublished = mock.MagicMock(return_value="pub-sha")
syncbuf = mock.MagicMock()
syncbuf.detach_head = False
proj.Sync_LocalHalf(syncbuf, force_rebase=False)
self.assertEqual(
proj._revlist.call_args_list,
[
mock.call("--count", "^HEAD", "new"),
mock.call("-1", "^new", "pub-sha"),
],
)
syncbuf.fail.assert_called_once()
def test_sync_network_half_stateless_skips_if_stash(self):
"""Test stateless sync skips if stash exists."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj._HasDirtyOrStash = mock.MagicMock(return_value=True)
res = proj.Sync_NetworkHalf()
self.assertTrue(res.success)
self.assertFalse(getattr(proj, "stateless_prune_needed", False))
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:
proj = self._get_project(tempdir)
proj.bare_git.rev_list.return_value = ["1"]
res = proj.Sync_NetworkHalf()
self.assertTrue(res.success)
self.assertFalse(getattr(proj, "stateless_prune_needed", False))
class SyncOptimizationTests(unittest.TestCase):
"""Tests for sync optimization logic involving shallow clones."""
def _get_project(
self,
tempdir: str,
depth: Optional[int] = None,
revisionExpr: Optional[str] = None,
) -> project.Project:
if revisionExpr is None:
revisionExpr = "0123456789abcdef0123456789abcdef01234567"
proj = _create_mock_project(
tempdir,
depth=depth,
gitdir=os.path.join(tempdir, "gitdir"),
objdir=os.path.join(tempdir, "objdir"),
revisionExpr=revisionExpr,
)
proj._CheckForImmutableRevision = mock.MagicMock(return_value=True)
proj.DeleteWorktree = mock.MagicMock()
proj._InitGitDir = mock.MagicMock()
proj._InitRemote = mock.MagicMock()
proj._InitMRef = mock.MagicMock()
return proj
def _create_sharing_project(self, tempdir, proj, share_objdir=True):
"""Create another project with the same name but a different gitdir.
Args:
share_objdir: a boolean, if True - the new project shares the same
objdir, if False - the new project has a different objdir.
"""
if share_objdir:
other_objdir = proj.objdir
else:
other_objdir = os.path.join(tempdir, "other_objdir")
other = project.Project(
manifest=proj.manifest,
name=proj.name,
remote=proj.remote,
gitdir=os.path.join(tempdir, "other_gitdir"),
objdir=other_objdir,
worktree=os.path.join(tempdir, "other_worktree"),
relpath="other-test-project",
revisionExpr=proj.revisionExpr,
revisionId=None,
)
proj.manifest.GetProjectsWithName.return_value.append(other)
return other
def test_sync_network_half_shallow_missing_fetches(self):
"""Test Sync_NetworkHalf fetches if shallow file is missing."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, depth=1)
# Ensure gitdir does not exist to simulate new project
if os.path.exists(proj.gitdir):
shutil.rmtree(proj.gitdir)
shallow_path = os.path.join(proj.gitdir, "shallow")
if os.path.exists(shallow_path):
os.unlink(shallow_path)
proj._RemoteFetch = mock.MagicMock(return_value=True)
res = proj.Sync_NetworkHalf(optimized_fetch=True)
self.assertTrue(res.success)
proj._RemoteFetch.assert_called_once()
def test_sync_network_half_shallow_exists_skips(self):
"""Test Sync_NetworkHalf skips fetch if shallow file exists."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, depth=1)
os.makedirs(proj.gitdir, exist_ok=True)
os.makedirs(proj.objdir, exist_ok=True)
with open(os.path.join(proj.gitdir, "shallow"), "w") as f:
f.write("")
proj._RemoteFetch = mock.MagicMock()
res = proj.Sync_NetworkHalf(optimized_fetch=True)
self.assertTrue(res.success)
proj._RemoteFetch.assert_not_called()
def test_sync_network_half_sharing_project_shallow_missing_fetches(
self,
):
"""Test Sync_NetworkHalf fetches when sharing project has shallow
file but this project does not."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, depth=1)
os.makedirs(proj.gitdir, exist_ok=True)
os.makedirs(proj.objdir, exist_ok=True)
other = self._create_sharing_project(tempdir, proj)
os.makedirs(other.gitdir, exist_ok=True)
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
f.write("")
proj._RemoteFetch = mock.MagicMock(return_value=True)
res = proj.Sync_NetworkHalf(optimized_fetch=True)
self.assertTrue(res.success)
proj._RemoteFetch.assert_called_once()
def test_sync_network_half_different_objdir_shallow_exists_skips(self):
"""Test Sync_NetworkHalf skips when same-name project has shallow file
but different objdir (like in a multi-manifest setup)."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, depth=1)
os.makedirs(proj.gitdir, exist_ok=True)
os.makedirs(proj.objdir, exist_ok=True)
other = self._create_sharing_project(
tempdir, proj, share_objdir=False
)
os.makedirs(other.gitdir, exist_ok=True)
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
f.write("")
proj._RemoteFetch = mock.MagicMock()
res = proj.Sync_NetworkHalf(optimized_fetch=True)
self.assertTrue(res.success)
proj._RemoteFetch.assert_not_called()
def test_sync_network_half_sharing_project_both_shallow_skips(self):
"""Test Sync_NetworkHalf skips when both this project and the sharing
project have shallow files."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, depth=1)
os.makedirs(proj.gitdir, exist_ok=True)
os.makedirs(proj.objdir, exist_ok=True)
with open(os.path.join(proj.gitdir, "shallow"), "w") as f:
f.write("")
other = self._create_sharing_project(tempdir, proj)
os.makedirs(other.gitdir, exist_ok=True)
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
f.write("")
proj._RemoteFetch = mock.MagicMock()
res = proj.Sync_NetworkHalf(optimized_fetch=True)
self.assertTrue(res.success)
proj._RemoteFetch.assert_not_called()
def test_remote_fetch_shallow_missing_fetches(self):
"""Test _RemoteFetch fetches if shallow file is missing."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, depth=1)
shallow_path = os.path.join(proj.gitdir, "shallow")
if os.path.exists(shallow_path):
os.unlink(shallow_path)
with mock.patch("project.GitCommand") as mock_git_cmd:
mock_cmd_instance = mock.MagicMock()
mock_cmd_instance.Wait.return_value = 0
mock_git_cmd.return_value = mock_cmd_instance
res = proj._RemoteFetch(
current_branch_only=True,
depth=1,
use_superproject=False,
)
self.assertTrue(res)
mock_git_cmd.assert_called()
def test_remote_fetch_shallow_exists_skips(self):
"""Test _RemoteFetch skips fetch if shallow file exists."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, depth=1)
os.makedirs(proj.gitdir, exist_ok=True)
os.makedirs(proj.objdir, exist_ok=True)
with open(os.path.join(proj.gitdir, "shallow"), "w") as f:
f.write("")
with mock.patch("project.GitCommand") as mock_git_cmd:
res = proj._RemoteFetch(
current_branch_only=True,
depth=1,
use_superproject=False,
)
self.assertTrue(res)
mock_git_cmd.assert_not_called()
def test_remote_fetch_sharing_project_shallow_missing_fetches(self):
"""Test _RemoteFetch fetches when sharing project has shallow file
but this project does not."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, depth=1)
os.makedirs(proj.gitdir, exist_ok=True)
os.makedirs(proj.objdir, exist_ok=True)
other = self._create_sharing_project(tempdir, proj)
os.makedirs(other.gitdir, exist_ok=True)
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
f.write("")
with mock.patch("project.GitCommand") as mock_git_cmd:
mock_cmd_instance = mock.MagicMock()
mock_cmd_instance.Wait.return_value = 0
mock_git_cmd.return_value = mock_cmd_instance
res = proj._RemoteFetch(
current_branch_only=True,
depth=1,
use_superproject=False,
)
self.assertTrue(res)
mock_git_cmd.assert_called()
def test_remote_fetch_sharing_project_both_shallow_skips(self):
"""Test _RemoteFetch skips when both this project and the sharing
project have shallow files."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, depth=1)
os.makedirs(proj.gitdir, exist_ok=True)
os.makedirs(proj.objdir, exist_ok=True)
with open(os.path.join(proj.gitdir, "shallow"), "w") as f:
f.write("")
other = self._create_sharing_project(tempdir, proj)
os.makedirs(other.gitdir, exist_ok=True)
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
f.write("")
with mock.patch("project.GitCommand") as mock_git_cmd:
res = proj._RemoteFetch(
current_branch_only=True,
depth=1,
use_superproject=False,
)
self.assertTrue(res)
mock_git_cmd.assert_not_called()
def test_check_immutable_revision_plain_project_upstream_ancestor(
self,
) -> None:
"""Non-shallow projects verify upstream ancestry for immutable
revisions."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
proj.bare_git = project.Project._GitGetByExec(
proj, bare=True, gitdir=proj.gitdir
)
proj.upstream = "refs/heads/main"
proj.work_git.config("remote.origin.url", "http://example.com/repo")
proj.work_git.config(
"remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"
)
test_file = os.path.join(tempdir, "file.txt")
with open(test_file, "w") as f:
f.write("commit1")
proj.work_git.add("file.txt")
proj.work_git.commit("-m", "commit 1")
commit1 = proj.work_git.rev_parse("HEAD")
proj.work_git.update_ref("refs/remotes/origin/main", commit1)
with open(test_file, "w") as f:
f.write("commit2")
proj.work_git.add("file.txt")
proj.work_git.commit("-m", "commit 2")
commit2 = proj.work_git.rev_parse("HEAD")
# 1. When revision is commit1 and upstream tracking ref is at
# commit2: commit1 is ancestor of origin/main -> True.
proj.revisionExpr = commit1
proj.work_git.update_ref("refs/remotes/origin/main", commit2)
self.assertTrue(
proj._CheckForImmutableRevision(use_superproject=False)
)
# 2. When revision is commit2 and upstream tracking ref is at
# commit1 (behind): commit2 is NOT an ancestor -> False.
proj.revisionExpr = commit2
proj.work_git.update_ref("refs/remotes/origin/main", commit1)
self.assertFalse(
proj._CheckForImmutableRevision(use_superproject=False)
)
# 3. In shallow mode (depth passed), upstream ancestry is skipped:
# commit2 exists in ODB, so shallow returns True even if upstream
# is behind.
self.assertTrue(
proj._CheckForImmutableRevision(use_superproject=False, depth=1)
)
# 4. If gitdir has shallow file, shallow check also skips upstream
# ancestry.
shallow_file = os.path.join(proj.gitdir, "shallow")
with open(shallow_file, "w") as f:
f.write("")
self.assertTrue(
proj._CheckForImmutableRevision(use_superproject=False)
)
os.unlink(shallow_file)
# 5. Tag revisions skip upstream ancestry verification: tag
# commits are immutable and do not track an upstream branch.
proj.revisionExpr = "refs/tags/v1.0"
proj.work_git.tag("-a", "v1.0", "-m", "tag v1.0", commit2)
self.assertTrue(
proj._CheckForImmutableRevision(use_superproject=False)
)
def test_sync_network_half_stale_upstream_fetches(self) -> None:
"""Sync_NetworkHalf does not skip fetch when upstream ref is behind."""
with utils_for_test.TempGitTree() as tempdir:
proj = _create_mock_project(tempdir)
proj.bare_git = project.Project._GitGetByExec(
proj, bare=True, gitdir=proj.gitdir
)
proj.upstream = "refs/heads/main"
proj.work_git.config("remote.origin.url", "http://example.com/repo")
proj.work_git.config(
"remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"
)
test_file = os.path.join(tempdir, "file.txt")
with open(test_file, "w") as f:
f.write("commit1")
proj.work_git.add("file.txt")
proj.work_git.commit("-m", "commit 1")
commit1 = proj.work_git.rev_parse("HEAD")
with open(test_file, "w") as f:
f.write("commit2")
proj.work_git.add("file.txt")
proj.work_git.commit("-m", "commit 2")
commit2 = proj.work_git.rev_parse("HEAD")
# Upstream ref is at commit1 (behind commit2).
proj.work_git.update_ref("refs/remotes/origin/main", commit1)
proj.revisionExpr = commit2
proj._InitRemote = mock.MagicMock()
proj._InitMRef = mock.MagicMock()
proj._RemoteFetch = mock.MagicMock(
return_value=project.SyncNetworkHalfResult(True)
)
res = proj.Sync_NetworkHalf(optimized_fetch=True)
self.assertTrue(res.success)
proj._RemoteFetch.assert_called_once()
def test_should_verify_upstream(self) -> None:
"""Test _ShouldVerifyUpstream conditions."""
sha = "4f8a3c0000000000000000000000000000000000"
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, revisionExpr=sha)
proj.upstream = "refs/heads/main"
# SHA revision with upstream on non-shallow project -> True.
self.assertTrue(proj._ShouldVerifyUpstream(use_superproject=False))
# Not a SHA (e.g. tag or branch name) -> False.
proj.revisionExpr = "refs/tags/v1.0"
self.assertFalse(proj._ShouldVerifyUpstream(use_superproject=False))
# SHA revision but no upstream -> False.
proj.revisionExpr = sha
proj.upstream = None
self.assertFalse(proj._ShouldVerifyUpstream(use_superproject=False))
# Shallow with depth -> False.
proj.upstream = "refs/heads/main"
self.assertFalse(
proj._ShouldVerifyUpstream(use_superproject=False, depth=1)
)
# Shallow with .git/shallow file -> False.
os.makedirs(proj.gitdir, exist_ok=True)
with open(os.path.join(proj.gitdir, "shallow"), "w") as f:
f.write("")
self.assertFalse(proj._ShouldVerifyUpstream(use_superproject=False))
def test_is_shallow_and_has_shallow(self) -> None:
"""Test _HasShallow and _IsShallow helpers."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
self.assertFalse(proj._HasShallow())
self.assertFalse(proj._IsShallow())
# depth makes _IsShallow True.
self.assertTrue(proj._IsShallow(depth=1))
self.assertFalse(proj._HasShallow())
# shallow file in gitdir makes both True.
os.makedirs(proj.gitdir, exist_ok=True)
with open(os.path.join(proj.gitdir, "shallow"), "w") as f:
f.write("")
self.assertTrue(proj._HasShallow())
self.assertTrue(proj._IsShallow())
def test_remote_fetch_sha1_dest_branch_not_fetched(self) -> None:
"""Test _RemoteFetch ignores dest-branch for SHA-1 revisions."""
sha = "4f8a3c0000000000000000000000000000000000"
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, revisionExpr=sha)
proj._CheckForImmutableRevision.return_value = False
proj.upstream = None
proj.dest_branch = "my-dest-branch"
proj.manifest.default.upstreamExpr = None
proj.manifest.default.revisionExpr = None
mock_remote = mock.MagicMock()
mock_remote.name = "origin"
def _to_local(r: str) -> str:
if r.startswith("refs/heads/"):
return "refs/remotes/origin/" + r[11:]
return r
mock_remote.ToLocal.side_effect = _to_local
mock_remote.PreConnectFetch.return_value = True
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
with mock.patch("project.GitCommand") as mock_git_cmd:
mock_cmd_instance = mock.MagicMock()
mock_cmd_instance.Wait.return_value = 0
mock_git_cmd.return_value = mock_cmd_instance
res = proj._RemoteFetch(current_branch_only=True)
self.assertTrue(res)
mock_git_cmd.assert_called_once()
cmd_args = mock_git_cmd.call_args[0][1]
self.assertIn("+refs/heads/*:refs/remotes/origin/*", cmd_args)
self.assertNotIn(
"+refs/heads/my-dest-branch:"
"refs/remotes/origin/my-dest-branch",
cmd_args,
)
def test_remote_fetch_sha1_manifest_default_fallback(self) -> None:
"""Test _RemoteFetch upstream fallback from manifest defaults."""
sha = "4f8a3c0000000000000000000000000000000000"
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, revisionExpr=sha)
proj._CheckForImmutableRevision.side_effect = [False, True]
proj.upstream = None
proj.dest_branch = None
proj.manifest.default.upstreamExpr = "manifest-upstream"
mock_remote = mock.MagicMock()
mock_remote.name = "origin"
def _to_local(r: str) -> str:
if r.startswith("refs/heads/"):
return "refs/remotes/origin/" + r[11:]
return r
mock_remote.ToLocal.side_effect = _to_local
mock_remote.PreConnectFetch.return_value = True
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
with mock.patch("project.GitCommand") as mock_git_cmd:
mock_cmd_instance = mock.MagicMock()
mock_cmd_instance.Wait.return_value = 0
mock_git_cmd.return_value = mock_cmd_instance
res = proj._RemoteFetch(current_branch_only=True)
self.assertTrue(res)
mock_git_cmd.assert_called_once()
cmd_args = mock_git_cmd.call_args[0][1]
self.assertIn(
"+refs/heads/manifest-upstream:"
"refs/remotes/origin/manifest-upstream",
cmd_args,
)
self.assertNotIn(
"+refs/heads/*:refs/remotes/origin/*", cmd_args
)
def test_remote_fetch_sha1_tag_not_fetched(self) -> None:
"""Test _RemoteFetch ignores tag defaults for SHA-1 revisions."""
sha = "4f8a3c0000000000000000000000000000000000"
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, revisionExpr=sha)
proj._CheckForImmutableRevision.return_value = False
proj.upstream = None
proj.dest_branch = None
proj.manifest.default.upstreamExpr = None
proj.manifest.default.revisionExpr = "refs/tags/v1.0"
mock_remote = mock.MagicMock()
mock_remote.name = "origin"
def _to_local(r: str) -> str:
if r.startswith("refs/heads/"):
return "refs/remotes/origin/" + r[11:]
return r
mock_remote.ToLocal.side_effect = _to_local
mock_remote.PreConnectFetch.return_value = True
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
with mock.patch("project.GitCommand") as mock_git_cmd:
mock_cmd_instance = mock.MagicMock()
mock_cmd_instance.Wait.return_value = 0
mock_git_cmd.return_value = mock_cmd_instance
res = proj._RemoteFetch(current_branch_only=True)
self.assertTrue(res)
mock_git_cmd.assert_called_once()
cmd_args = mock_git_cmd.call_args[0][1]
self.assertIn("+refs/heads/*:refs/remotes/origin/*", cmd_args)
self.assertNotIn("tag", cmd_args)
self.assertNotIn("v1.0", cmd_args)
def test_remote_fetch_sha1_metaproject_without_manifest_xml(self) -> None:
"""Test MetaProject _RemoteFetch with SHA-1 fetches all branches."""
sha = "4f8a3c0000000000000000000000000000000000"
with utils_for_test.TempGitTree() as tempdir:
repodir = os.path.join(tempdir, ".repo")
manifest_dir = os.path.join(repodir, "manifests")
manifest_file = os.path.join(
repodir, manifest_xml.MANIFEST_FILE_NAME
)
os.mkdir(repodir)
os.mkdir(manifest_dir)
manifest = manifest_xml.XmlManifest(repodir, manifest_file)
proj = project.ManifestProject(
manifest,
"test/manifest",
os.path.join(tempdir, ".git"),
tempdir,
)
proj.revisionExpr = sha
proj.upstream = None
proj._CheckForImmutableRevision = mock.MagicMock(return_value=False)
mock_remote = mock.MagicMock()
mock_remote.name = "origin"
def _to_local(r: str) -> str:
if r.startswith("refs/heads/"):
return "refs/remotes/origin/" + r[11:]
return r
mock_remote.ToLocal.side_effect = _to_local
mock_remote.PreConnectFetch.return_value = True
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
with mock.patch("project.GitCommand") as mock_git_cmd:
mock_cmd_instance = mock.MagicMock()
mock_cmd_instance.Wait.return_value = 0
mock_git_cmd.return_value = mock_cmd_instance
res = proj._RemoteFetch(current_branch_only=True)
self.assertTrue(res)
mock_git_cmd.assert_called_once()
cmd_args = mock_git_cmd.call_args[0][1]
self.assertIn("+refs/heads/*:refs/remotes/origin/*", cmd_args)
def test_remote_fetch_sha1_none_manifest_default(self) -> None:
"""Test _GetUpstreamFallback when manifest.default is None."""
sha = "4f8a3c0000000000000000000000000000000000"
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, revisionExpr=sha)
proj.dest_branch = None
proj.manifest.default = None
self.assertIsNone(proj._GetUpstreamFallback())
def test_get_upstream_fallback_ignores_dest_branch(self) -> None:
"""dest_branch alone yields no fallback upstream."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(
tempdir,
revisionExpr="0123456789abcdef0123456789abcdef01234567",
)
proj.dest_branch = "main"
proj.manifest.default.upstreamExpr = None
proj.manifest.default.revisionExpr = None
self.assertIsNone(proj._GetUpstreamFallback())
def test_get_upstream_fallback_ignores_tag(self) -> None:
"""A tag default is not returned as a fallback upstream."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(
tempdir,
revisionExpr="0123456789abcdef0123456789abcdef01234567",
)
proj.dest_branch = None
proj.manifest.default.upstreamExpr = None
proj.manifest.default.revisionExpr = "refs/tags/v1.0"
self.assertIsNone(proj._GetUpstreamFallback())
def test_get_upstream_fallback_uses_revision_branch(self) -> None:
"""A branch revision default is a valid fallback upstream."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(
tempdir,
revisionExpr="0123456789abcdef0123456789abcdef01234567",
)
proj.dest_branch = "dest-should-be-ignored"
proj.manifest.default.upstreamExpr = None
proj.manifest.default.revisionExpr = "main"
self.assertEqual(proj._GetUpstreamFallback(), "main")
def test_remote_fetch_sha1_missing_upstream_falls_back_to_all_heads(
self,
) -> None:
"""A missing upstream remote branch falls back to fetching all heads."""
sha = "4f8a3c0000000000000000000000000000000000"
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir, revisionExpr=sha)
proj._CheckForImmutableRevision = mock.MagicMock(return_value=False)
proj.upstream = None
proj.dest_branch = None
proj.manifest.default.upstreamExpr = None
proj.manifest.default.revisionExpr = "master"
mock_remote = mock.MagicMock()
mock_remote.name = "origin"
def _to_local(r: str) -> str:
if r.startswith("refs/heads/"):
return "refs/remotes/origin/" + r[11:]
return r
mock_remote.ToLocal.side_effect = _to_local
mock_remote.PreConnectFetch.return_value = True
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
fetched_specs: List[List[str]] = []
def _fake_git_command(
_proj: Optional[project.Project],
cmd: List[str],
**kwargs,
) -> mock.MagicMock:
fetched_specs.append(cmd)
inst = mock.MagicMock()
if "+refs/heads/master:refs/remotes/origin/master" in cmd:
inst.Wait.return_value = 128
inst.stdout = (
"fatal: couldn't find remote ref refs/heads/master\n"
)
else:
inst.Wait.return_value = 0
inst.stdout = ""
return inst
with mock.patch(
"project.GitCommand", side_effect=_fake_git_command
):
res = proj._RemoteFetch(current_branch_only=True)
self.assertTrue(res)
self.assertTrue(
any(
"+refs/heads/master:refs/remotes/origin/master" in c
for c in fetched_specs
)
)
self.assertTrue(
any(
"+refs/heads/*:refs/remotes/origin/*" in c
for c in fetched_specs
)
)
class GetEnvVarsTests(unittest.TestCase):
"""Tests for GetEnvVars project environment variable generation."""
def _get_project(self, tempdir, revisionExpr="main"):
proj = _create_mock_project(tempdir, revisionExpr=revisionExpr)
proj.GetRevisionId = mock.MagicMock(return_value="1234abcd")
return proj
def test_get_env_vars_basic(self):
"""Test that all basic environment variables are set correctly."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.manifest.path_prefix = "sub-manifest"
proj.upstream = "upstream-branch"
proj.dest_branch = "dest-branch"
env = proj.GetEnvVars(local=True)
self.assertEqual(env["REPO_PROJECT"], "test-project")
self.assertEqual(env["REPO_OUTERPATH"], "sub-manifest")
self.assertEqual(env["REPO_INNERPATH"], "test-project")
self.assertEqual(env["REPO_PATH"], "test-project")
self.assertEqual(env["REPO_REMOTE"], "origin")
self.assertEqual(env["REPO_LREV"], "1234abcd")
self.assertEqual(env["REPO_RREV"], "main")
self.assertEqual(env["REPO_UPSTREAM"], "upstream-branch")
self.assertEqual(env["REPO_DEST_BRANCH"], "dest-branch")
self.assertEqual(
env["REPO_PROJECT_FETCH_URL"], "http://example.com/repo"
)
def test_get_env_vars_non_local(self):
"""Test environment variables generation with local=False."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.manifest.path_prefix = "sub-manifest"
env = proj.GetEnvVars(local=False)
# REPO_PATH should be relative to outermost manifest
# (sub-manifest/test-project)
self.assertEqual(env["REPO_PATH"], "sub-manifest/test-project")
def test_get_env_vars_mirror(self):
"""Test environment variables generation in mirror mode."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.manifest.IsMirror = True
env = proj.GetEnvVars()
# In mirror mode, REPO_LREV should be empty, and GetRevisionId must
# not be called
self.assertEqual(env["REPO_LREV"], "")
proj.GetRevisionId.assert_not_called()
def test_get_env_vars_annotations(self):
"""Test that project annotations are added correctly."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
annotation1 = mock.MagicMock()
annotation1.name = "key1"
annotation1.value = "value1"
annotation2 = mock.MagicMock()
annotation2.name = "key2"
annotation2.value = "value2"
proj.annotations = [annotation1, annotation2]
env = proj.GetEnvVars()
self.assertEqual(env["REPO__key1"], "value1")
self.assertEqual(env["REPO__key2"], "value2")
def test_get_env_vars_invalid_revision_graceful(self):
"""Test that invalid revision error is handled gracefully."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.GetRevisionId.side_effect = error.ManifestInvalidRevisionError(
"revision not found"
)
env = proj.GetEnvVars()
self.assertEqual(env["REPO_LREV"], "")
def _create_manifest_project(tempdir: str) -> project.ManifestProject:
"""Return a ManifestProject for a new .repo/ under |tempdir|."""
repodir = os.path.join(tempdir, ".repo")
manifest_dir = os.path.join(repodir, "manifests")
manifest_file = os.path.join(repodir, manifest_xml.MANIFEST_FILE_NAME)
os.mkdir(repodir)
os.mkdir(manifest_dir)
manifest = manifest_xml.XmlManifest(repodir, manifest_file)
return project.ManifestProject(
manifest, "test/manifest", os.path.join(tempdir, ".git"), tempdir
)
class FetchCmdTests(unittest.TestCase):
"""Tests for fetch_cmd feature."""
def setUpManifest(self, tempdir):
return _create_manifest_project(tempdir)
def _get_project(self, tempdir):
proj = _create_mock_project(
tempdir, use_local_gitdirs=True, fetch_cmd="echo hi"
)
proj.GetRevisionId = mock.MagicMock(return_value="1234abcd")
return proj
def test_fetch_cmd_execution(self):
"""Test that fetch_cmd is executed with correct environment."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.bare_git.rev_parse.return_value = "1234abcd"
mock_remote = mock.MagicMock()
mock_remote.ToLocal.return_value = "refs/remotes/origin/main"
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.MagicMock(returncode=0, stderr="")
res = proj._CustomFetch()
self.assertTrue(res)
mock_run.assert_called_once()
args, kwargs = mock_run.call_args
self.assertEqual(args[0], "echo hi")
self.assertEqual(kwargs["shell"], True)
self.assertEqual(kwargs["cwd"], tempdir)
self.assertEqual(kwargs["env"]["REPO_TREV"], "1234abcd")
self.assertEqual(
kwargs["env"]["REPO_PROJECT_FETCH_URL"],
"http://example.com/repo",
)
def test_sync_fetch_cmd_requires_use_local_gitdirs(self):
"""Test that fetch_cmd requires use_local_gitdirs."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = self.setUpManifest(tempdir)
class DummyManifest:
is_submanifest = False
def GetDefaultGroupsStr(self, with_platform=False):
return ""
fakeproj.manifest = DummyManifest()
fakeproj.config.SetString("repo.fetchcmd", "echo hi")
fakeproj.config.SetBoolean("repo.uselocalgitdirs", False)
result = fakeproj.Sync(use_local_gitdirs=False)
self.assertFalse(result)
class ReprojectCmdTests(unittest.TestCase):
"""Tests for the repo.reprojectcmd feature."""
REVID = "1234abcd" * 5
HEAD_ID = "5678abcd" * 5
HEAD_TREE = "cafe0000" * 5
OTHER_ID = "9abcdef0" * 5
PUB_ID = "0fedcba9" * 5
def _get_project(self, tempdir: str) -> project.Project:
proj = _create_mock_project(
tempdir, use_local_gitdirs=True, reproject_cmd="echo reproject"
)
proj.manifest.path_prefix = ""
proj.GetRevisionId = mock.MagicMock(return_value=self.REVID)
proj.IsRebaseInProgress = mock.MagicMock(return_value=False)
proj.IsCherryPickInProgress = mock.MagicMock(return_value=False)
proj.work_git = mock.MagicMock()
proj.work_git.GetHead.return_value = self.HEAD_ID
proj.work_git.rev_parse.return_value = self.HEAD_TREE
proj.work_git.GetDotgitPath.side_effect = lambda subpath: os.path.join(
tempdir, ".git", subpath
)
return proj
@staticmethod
def _z(*items: str) -> str:
"""Return |items| as NUL-delimited Git output."""
return "".join(item + "\0" for item in items)
@staticmethod
def _git_command(
staged: str = "", diff: str = "", returncode: int = 0
) -> Callable[..., mock.MagicMock]:
"""Return a GitCommand stand-in answering the reproject queries.
Args:
staged: `git diff-index -z --cached` or `git ls-files -z` output.
diff: `git diff-index --cached` postcondition output.
returncode: exit code of GitCommand.
"""
def make(
project: project.Project, cmdv: List[str], **kwargs: Any
) -> mock.MagicMock:
cmd = mock.MagicMock()
cmd.stderr = ""
if cmdv[0] == "diff-index":
if any("^{tree}" in arg for arg in cmdv):
cmd.stdout = diff
else:
cmd.stdout = staged
elif cmdv[0] == "ls-files":
cmd.stdout = staged
else:
cmd.stdout = ""
cmd.Wait.return_value = returncode
return cmd
return make
def _reproject(
self, proj: project.Project, **outputs: Any
) -> mock.MagicMock:
"""Run _Reproject against mocked Git; return the subprocess mock."""
with mock.patch(
"project.GitCommand", side_effect=self._git_command(**outputs)
), mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
proj._Reproject(self.REVID)
return mock_run
def test_reproject_runs_the_command_with_project_env(self) -> None:
"""Test the command runs from the client root with REPO_TREV set."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
with mock.patch(
"project.GitCommand", side_effect=self._git_command()
), mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
proj._Reproject(self.REVID)
mock_run.assert_called_once()
args, kwargs = mock_run.call_args
self.assertEqual(args[0], "echo reproject")
self.assertTrue(kwargs["shell"])
self.assertEqual(kwargs["cwd"], tempdir)
self.assertEqual(kwargs["env"]["REPO_TREV"], self.REVID)
self.assertEqual(kwargs["env"]["REPO_PATH"], "test-project")
def test_reproject_rejects_a_staged_change(self) -> None:
"""Test a staged change fails before the command runs."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
with self.assertRaises(project.LocalSyncFail) as e:
self._reproject(proj, staged=self._z("lib/a.c"))
self.assertIn(
"reprojectcmd cannot run with staged changes", str(e.exception)
)
self.assertIn("lib/a.c", str(e.exception))
def test_reproject_unborn_head_rejects_a_staged_change(self) -> None:
"""Test a staged change on unborn HEAD fails before command runs."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.work_git.rev_parse.side_effect = error.GitError("unborn")
with self.assertRaises(project.LocalSyncFail) as e:
self._reproject(proj, staged=self._z("lib/a.c"))
self.assertIn(
"reprojectcmd cannot run with staged changes", str(e.exception)
)
self.assertIn("lib/a.c", str(e.exception))
def test_reproject_unborn_head_runs_clean(self) -> None:
"""Test a clean unborn HEAD allows the command to run."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.work_git.rev_parse.side_effect = error.GitError("unborn")
mock_run = self._reproject(proj)
mock_run.assert_called_once()
def test_reproject_rejects_an_operation_in_progress(self) -> None:
"""Test an unfinished rebase fails the sync before the command runs."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.IsRebaseInProgress.return_value = True
with mock.patch("subprocess.run") as mock_run:
with self.assertRaises(project.LocalSyncFail) as e:
proj._Reproject(self.REVID)
self.assertIn("rebase in progress", str(e.exception))
mock_run.assert_not_called()
def test_reproject_surfaces_a_failed_command(self) -> None:
"""Test a non-zero exit fails the sync with the command's output."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
with mock.patch(
"project.GitCommand", side_effect=self._git_command()
), mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.MagicMock(
returncode=3, stdout="disk on fire\n"
)
with self.assertRaises(project.LocalSyncFail) as e:
proj._Reproject(self.REVID)
self.assertIn("exited with 3", str(e.exception))
self.assertIn("disk on fire", str(e.exception))
def test_reproject_rejects_a_moved_head_ref(self) -> None:
"""Test a command that wrote HEAD fails the sync."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.work_git.GetHead.side_effect = [self.HEAD_ID, self.REVID]
with mock.patch(
"project.GitCommand", side_effect=self._git_command()
), mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
with self.assertRaises(project.LocalSyncFail) as e:
proj._Reproject(self.REVID)
self.assertIn("moved HEAD", str(e.exception))
def test_reproject_rejects_a_moved_head_oid_on_branch(self) -> None:
"""Test a command that moved a branch pointer fails the sync."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
proj.work_git.GetHead.return_value = "refs/heads/main"
proj.work_git.rev_parse.side_effect = [
"tree123", # HEAD^{tree}
self.HEAD_ID, # HEAD before command
self.REVID, # HEAD after command
]
with mock.patch(
"project.GitCommand", side_effect=self._git_command()
), mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
with self.assertRaises(project.LocalSyncFail) as e:
proj._Reproject(self.REVID)
self.assertIn("moved HEAD", str(e.exception))
def test_reproject_rejects_an_index_mismatch(self) -> None:
"""Test a command that left the index off the target fails the sync."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_project(tempdir)
with mock.patch(
"project.GitCommand",
side_effect=self._git_command(diff="lib/a.c\n"),
), mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
with self.assertRaises(project.LocalSyncFail) as e:
proj._Reproject(self.REVID)
self.assertIn(self.REVID, str(e.exception))
self.assertIn("lib/a.c", str(e.exception))
def _get_synced_project(
self,
tempdir: str,
head: Optional[str],
branch: Optional[str] = None,
upstream_gain: Sequence[str] = (),
local_changes: Sequence[str] = (),
) -> project.Project:
"""Return a project ready for Sync_LocalHalf with Git mocked out.
Args:
head: The commit HEAD is at, or None for an unborn branch.
branch: The name of the checked out branch, or None if detached.
upstream_gain: The commits the target has that HEAD lacks.
local_changes: The "<sha> <email>" lines HEAD has that the target
lacks.
"""
proj = self._get_project(tempdir)
for name in (
"_InitWorkTree",
"CleanPublishedCache",
"_CopyAndLinkFiles",
"_Reproject",
"_Checkout",
"_FastForward",
"_ResetHard",
"_Rebase",
):
setattr(proj, name, mock.MagicMock())
proj.IsDirty = mock.MagicMock(return_value=False)
proj._userident_name = "Me"
proj._userident_email = "me@example.com"
proj.bare_ref = mock.MagicMock()
if branch:
proj.bare_ref.head = project.R_HEADS + branch
proj.bare_ref.all = {project.R_HEADS + branch: head} if head else {}
# A branch that does not track upstream; tests that need one
# tracking upstream replace this with _tracking_branch().
proj.GetBranch = mock.MagicMock(
return_value=self._tracking_branch(branch, merge=None)
)
else:
proj.bare_ref.head = head
proj.bare_ref.all = {}
def _revlist(*args: Any, **kwargs: Any) -> List[str]:
if kwargs.get("format"):
return list(local_changes)
if project.not_rev(project.HEAD) in args:
if "--count" in args:
return [str(len(upstream_gain))]
return list(upstream_gain)
if self.PUB_ID in args:
return [self.PUB_ID]
return []
proj._revlist = mock.MagicMock(side_effect=_revlist)
return proj
@staticmethod
def _tracking_branch(
name: str = "topic", merge: Optional[str] = "main"
) -> mock.MagicMock:
"""Return a branch tracking |merge| upstream, or nothing if None."""
branch = mock.MagicMock()
branch.name = name
branch.merge = merge
branch.LocalMerge = f"refs/remotes/origin/{merge}" if merge else None
return branch
def test_sync_local_half_materializes_a_fresh_project(self) -> None:
"""Test a project with an unborn HEAD is checked out by the command."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_synced_project(tempdir, head=None, branch="main")
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertTrue(syncbuf.Finish())
proj._Reproject.assert_called_once_with(self.REVID, verbose=False)
proj.work_git.DetachHead.assert_called_once_with(
self.REVID,
message=f"checkout: moving from main to {self.REVID}",
)
proj._Checkout.assert_not_called()
proj._CopyAndLinkFiles.assert_called_once_with()
def test_sync_local_half_detached_head_uses_the_command(self) -> None:
"""Test a detached HEAD is moved to the target by the command."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_synced_project(tempdir, head=self.HEAD_ID)
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertTrue(syncbuf.Finish())
proj._Reproject.assert_called_once_with(self.REVID, verbose=False)
proj.work_git.DetachHead.assert_called_once_with(
self.REVID,
message=f"checkout: moving from {self.HEAD_ID} to {self.REVID}",
)
proj._Checkout.assert_not_called()
def test_sync_local_half_head_at_target_skips_the_command(self) -> None:
"""Test `repo sync -d` at the target only detaches HEAD."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_synced_project(
tempdir, head=self.REVID, branch="topic"
)
syncbuf = project.SyncBuffer(proj.config, detach_head=True)
proj.Sync_LocalHalf(syncbuf)
self.assertTrue(syncbuf.Finish())
proj._Reproject.assert_not_called()
proj.work_git.DetachHead.assert_called_once()
self.assertEqual(
proj.work_git.DetachHead.call_args[0][0], self.REVID
)
def test_sync_local_half_command_failure_fails_the_project(self) -> None:
"""Test a failed command is reported and leaves the ref alone."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_synced_project(tempdir, head=self.HEAD_ID)
proj._Reproject.side_effect = project.LocalSyncFail(
"reprojectcmd exited with 1", project=proj.name
)
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertFalse(syncbuf.Finish())
self.assertEqual(len(syncbuf.errors), 1)
self.assertIn("exited with 1", str(syncbuf.errors[0]))
proj.work_git.DetachHead.assert_not_called()
proj._CopyAndLinkFiles.assert_not_called()
def test_sync_local_half_fast_forward_uses_the_command(self) -> None:
"""Test a branch behind the target is fast-forwarded by the command."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_synced_project(
tempdir,
head=self.HEAD_ID,
branch="topic",
upstream_gain=[self.REVID],
)
proj.GetBranch = mock.MagicMock(
return_value=self._tracking_branch()
)
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertTrue(syncbuf.Finish())
proj._Reproject.assert_called_once_with(self.REVID, verbose=False)
proj.work_git.UpdateRef.assert_called_once_with(
project.HEAD,
self.REVID,
old=self.HEAD_ID,
message=f"merge {self.REVID}: Fast-forward",
)
proj._FastForward.assert_not_called()
proj._CopyAndLinkFiles.assert_called_once_with()
def test_sync_local_half_head_ahead_skips_the_command(self) -> None:
"""Test a published branch ahead of the target is left alone."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_synced_project(
tempdir, head=self.HEAD_ID, branch="topic"
)
proj.GetBranch = mock.MagicMock(
return_value=self._tracking_branch()
)
proj.work_git.merge_base.side_effect = error.GitError("no")
proj.WasPublished = mock.MagicMock(return_value=self.PUB_ID)
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertTrue(syncbuf.Finish())
proj._Reproject.assert_not_called()
proj.work_git.UpdateRef.assert_not_called()
proj._FastForward.assert_not_called()
proj._CopyAndLinkFiles.assert_called_once_with()
def test_sync_local_half_hard_reset_uses_the_command(self) -> None:
"""Test a branch whose commits upstream dropped is reset by it."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_synced_project(
tempdir,
head=self.HEAD_ID,
branch="topic",
upstream_gain=[self.REVID],
local_changes=[f"{self.OTHER_ID} other@example.com"],
)
proj.GetBranch = mock.MagicMock(
return_value=self._tracking_branch()
)
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertTrue(syncbuf.Finish())
proj._Reproject.assert_called_once_with(self.REVID, verbose=False)
proj.work_git.UpdateRef.assert_called_once_with(
project.HEAD,
self.REVID,
old=self.HEAD_ID,
message=f"reset: moving to {self.REVID}",
)
proj._ResetHard.assert_not_called()
def test_sync_local_half_rebase_is_not_delegated(self) -> None:
"""Test a branch carrying the user's commits is rebased by Git."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_synced_project(
tempdir,
head=self.HEAD_ID,
branch="topic",
upstream_gain=[self.REVID],
local_changes=[f"{self.OTHER_ID} me@example.com"],
)
proj.GetBranch = mock.MagicMock(
return_value=self._tracking_branch()
)
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertTrue(syncbuf.Finish())
proj._Rebase.assert_called_once_with(
upstream=f"{self.OTHER_ID}^1", onto=self.REVID
)
proj._Reproject.assert_not_called()
proj.work_git.UpdateRef.assert_not_called()
def test_sync_local_half_rejects_a_nested_project(self) -> None:
"""Test a submodule or nested project fails before any checkout."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_synced_project(tempdir, head=self.HEAD_ID)
proj.parent = mock.MagicMock()
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertFalse(syncbuf.Finish())
self.assertIn("nested", str(syncbuf.errors[0]))
proj._InitWorkTree.assert_not_called()
proj._Reproject.assert_not_called()
def test_sync_local_half_without_the_command_uses_git(self) -> None:
"""Test Git keeps doing the checkout when the command is not set."""
with utils_for_test.TempGitTree() as tempdir:
proj = self._get_synced_project(tempdir, head=self.HEAD_ID)
proj.manifest.manifestProject.reproject_cmd = None
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertTrue(syncbuf.Finish())
proj._Checkout.assert_called_once_with(
self.REVID, force_checkout=False, quiet=True
)
proj._Reproject.assert_not_called()
proj.work_git.DetachHead.assert_not_called()
def test_metaproject_never_uses_the_command(self) -> None:
"""Test .repo/manifests and .repo/repo are checked out by Git."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = _create_manifest_project(tempdir)
fakeproj.config.SetString("repo.reprojectcmd", "echo hi")
fakeproj.config.SetBoolean("repo.uselocalgitdirs", True)
self.assertFalse(fakeproj.UseReprojectCmd)
def test_sync_reproject_cmd_requires_use_local_gitdirs(self) -> None:
"""Test that repo.reprojectcmd requires repo.uselocalgitdirs."""
with utils_for_test.TempGitTree() as tempdir:
fakeproj = _create_manifest_project(tempdir)
class DummyManifest:
is_submanifest = False
def GetDefaultGroupsStr(
self, with_platform: bool = False
) -> str:
return ""
fakeproj.manifest = DummyManifest()
fakeproj.config.SetString("repo.reprojectcmd", "echo hi")
fakeproj.config.SetBoolean("repo.uselocalgitdirs", False)
result = fakeproj.Sync(use_local_gitdirs=False)
self.assertFalse(result)
class ReprojectCmdGitTests(unittest.TestCase):
"""Tests running the reprojectcmd contract against a real Git checkout."""
READ_TREE = "git -C $REPO_PATH read-tree -m -u $REPO_TREV"
@staticmethod
def _git(cwd: str, *args: str) -> str:
return subprocess.run(
["git", "-C", cwd] + list(args),
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
).stdout.rstrip("\n")
def _make_client(
self, topdir: str, reproject_cmd: Optional[str]
) -> Tuple[project.Project, str]:
"""Set up a fetched, never checked out project under |topdir|.
Returns:
The project and the commit its manifest revision names.
"""
# A remote holding the history the project fetches.
remote = os.path.join(topdir, "remote")
os.mkdir(remote)
self._git(remote, "init", "-q")
self._git(remote, "symbolic-ref", "HEAD", "refs/heads/main")
for msg, files in (
("one", {"one.txt": "one\n", "keep.txt": "keep\n"}),
("two", {"one.txt": "one, revised\n", "two.txt": "two\n"}),
):
for name, content in files.items():
with open(os.path.join(remote, name), "w") as fp:
fp.write(content)
self._git(remote, "add", name)
self._git(
remote,
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-q",
"-m",
msg,
)
revid = self._git(remote, "rev-parse", "HEAD")
# The project as repo.fetchcmd leaves it: objects fetched, HEAD on an
# unborn branch, nothing in the index or the worktree.
worktree = os.path.join(topdir, "proj")
os.mkdir(worktree)
self._git(worktree, "init", "-q")
self._git(worktree, "symbolic-ref", "HEAD", "refs/heads/main")
self._git(worktree, "fetch", "-q", remote, "refs/heads/main")
manifest = mock.MagicMock()
manifest.manifestProject.use_local_gitdirs = True
manifest.manifestProject.reproject_cmd = reproject_cmd
manifest.UseLocalGitDirs = True
manifest.IsMirror = False
manifest.is_multimanifest = False
manifest.topdir = topdir
manifest.path_prefix = ""
manifest.globalConfig = None
remote_spec = mock.MagicMock()
remote_spec.name = "origin"
remote_spec.url = remote
proj = project.Project(
manifest=manifest,
name="proj",
remote=remote_spec,
gitdir=os.path.join(worktree, ".git"),
objdir=os.path.join(worktree, ".git"),
worktree=worktree,
relpath="proj",
revisionExpr="main",
revisionId=revid,
)
proj._Checkout = mock.MagicMock(
side_effect=AssertionError("Git must not do the checkout")
)
return proj, revid
def _sync(self, proj: project.Project) -> Tuple[bool, List[Any]]:
"""Run Sync_LocalHalf; return whether it succeeded and its errors."""
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
return syncbuf.Finish(), syncbuf.errors
def _step_back(self, proj: project.Project, revid: str) -> str:
"""Put the project cleanly at the commit before |revid|, with Git."""
parent = self._git(proj.worktree, "rev-parse", revid + "~1")
self._git(proj.worktree, "update-ref", "--no-deref", "HEAD", parent)
self._git(proj.worktree, "read-tree", "-u", "--reset", "HEAD")
return parent
def test_sync_local_half_checks_out_a_fresh_project(self) -> None:
"""Test the read-tree command materializes a project like Git would."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as topdir:
marker = os.path.join(topdir, "ran")
proj, revid = self._make_client(
topdir, f"{self.READ_TREE} && touch {marker}"
)
worktree = proj.worktree
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertTrue(syncbuf.Finish(), syncbuf.errors)
self.assertTrue(os.path.exists(marker))
self.assertEqual(self._git(worktree, "rev-parse", "HEAD"), revid)
with self.assertRaises(subprocess.CalledProcessError):
self._git(worktree, "symbolic-ref", "-q", "HEAD")
self.assertEqual(self._git(worktree, "status", "--porcelain"), "")
with open(os.path.join(worktree, "one.txt")) as fp:
self.assertEqual(fp.read(), "one, revised\n")
with open(os.path.join(worktree, "two.txt")) as fp:
self.assertEqual(fp.read(), "two\n")
# Syncing again finds HEAD at the target and leaves it alone.
os.remove(marker)
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertTrue(syncbuf.Finish(), syncbuf.errors)
self.assertFalse(os.path.exists(marker))
self.assertEqual(self._git(worktree, "rev-parse", "HEAD"), revid)
def test_sync_local_half_leaves_an_untracked_file_in_the_way_alone(
self,
) -> None:
"""Test an untracked file the target adds fails without any change."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as topdir:
proj, revid = self._make_client(topdir, self.READ_TREE)
worktree = proj.worktree
with open(os.path.join(worktree, "two.txt"), "w") as fp:
fp.write("mine\n")
clean, errors = self._sync(proj)
self.assertFalse(clean)
self.assertIn("two.txt", str(errors[0]))
self.assertEqual(
self._git(worktree, "symbolic-ref", "HEAD"), "refs/heads/main"
)
self.assertEqual(sorted(os.listdir(worktree)), [".git", "two.txt"])
with open(os.path.join(worktree, "two.txt")) as fp:
self.assertEqual(fp.read(), "mine\n")
def test_sync_local_half_keeps_local_changes_out_of_the_way(self) -> None:
"""Test edits and untracked files the target leaves alone survive."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as topdir:
proj, revid = self._make_client(topdir, self.READ_TREE)
worktree = proj.worktree
self.assertTrue(self._sync(proj)[0])
self._step_back(proj, revid)
with open(os.path.join(worktree, "keep.txt"), "w") as fp:
fp.write("edited\n")
with open(os.path.join(worktree, "junk"), "w") as fp:
fp.write("junk\n")
clean, errors = self._sync(proj)
self.assertTrue(clean, errors)
self.assertEqual(self._git(worktree, "rev-parse", "HEAD"), revid)
with open(os.path.join(worktree, "one.txt")) as fp:
self.assertEqual(fp.read(), "one, revised\n")
with open(os.path.join(worktree, "keep.txt")) as fp:
self.assertEqual(fp.read(), "edited\n")
self.assertEqual(
self._git(worktree, "status", "--porcelain").splitlines(),
[" M keep.txt", "?? junk"],
)
def test_sync_local_half_rejects_a_local_change_in_the_way(self) -> None:
"""Test an edit to a file the target changes fails without a change."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as topdir:
proj, revid = self._make_client(topdir, self.READ_TREE)
worktree = proj.worktree
self.assertTrue(self._sync(proj)[0])
parent = self._step_back(proj, revid)
with open(os.path.join(worktree, "one.txt"), "w") as fp:
fp.write("edited\n")
clean, errors = self._sync(proj)
self.assertFalse(clean)
self.assertIn("one.txt", str(errors[0]))
self.assertEqual(self._git(worktree, "rev-parse", "HEAD"), parent)
with open(os.path.join(worktree, "one.txt")) as fp:
self.assertEqual(fp.read(), "edited\n")
self.assertNotIn("two.txt", os.listdir(worktree))
def test_sync_local_half_rejects_a_command_that_moves_head(self) -> None:
"""Test a command that writes HEAD fails the postcondition."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as topdir:
proj, revid = self._make_client(
topdir,
f"{self.READ_TREE} && git -C $REPO_PATH update-ref "
"--no-deref HEAD $REPO_TREV",
)
syncbuf = project.SyncBuffer(proj.config)
proj.Sync_LocalHalf(syncbuf)
self.assertFalse(syncbuf.Finish())
self.assertIn("moved HEAD", str(syncbuf.errors[0]))
def test_sync_local_half_rejects_staged_changes(self) -> None:
"""Test a staged change fails before the command runs."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as topdir:
proj, revid = self._make_client(topdir, self.READ_TREE)
worktree = proj.worktree
self.assertTrue(self._sync(proj)[0])
self._step_back(proj, revid)
with open(os.path.join(worktree, "keep.txt"), "w") as fp:
fp.write("staged\n")
self._git(worktree, "add", "keep.txt")
clean, errors = self._sync(proj)
self.assertFalse(clean)
self.assertIn("staged changes", str(errors[0]))
self.assertIn("keep.txt", str(errors[0]))
def test_sync_local_half_rejects_a_command_that_moves_branch_tip(
self,
) -> None:
"""Test a command that moves the branch commit fails postcondition."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as topdir:
proj, revid = self._make_client(
topdir,
f"{self.READ_TREE} && git -C $REPO_PATH update-ref "
"refs/heads/main $REPO_TREV",
)
clean, errors = self._sync(proj)
self.assertFalse(clean)
self.assertIn("moved HEAD", str(errors[0]))
def test_sync_local_half_keeps_local_changes_on_tracking_branch(
self,
) -> None:
"""Test benign edits survive fast-forward on a tracking branch."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as topdir:
proj, revid = self._make_client(topdir, self.READ_TREE)
worktree = proj.worktree
self.assertTrue(self._sync(proj)[0])
parent = self._step_back(proj, revid)
self._git(worktree, "checkout", "-q", "-b", "main", parent)
self._git(worktree, "config", "branch.main.remote", "origin")
self._git(
worktree, "config", "branch.main.merge", "refs/heads/main"
)
with open(os.path.join(worktree, "keep.txt"), "w") as fp:
fp.write("edited\n")
with open(os.path.join(worktree, "junk"), "w") as fp:
fp.write("junk\n")
clean, errors = self._sync(proj)
self.assertTrue(clean, errors)
self.assertEqual(self._git(worktree, "rev-parse", "HEAD"), revid)
with open(os.path.join(worktree, "one.txt")) as fp:
self.assertEqual(fp.read(), "one, revised\n")
with open(os.path.join(worktree, "keep.txt")) as fp:
self.assertEqual(fp.read(), "edited\n")
self.assertEqual(
self._git(worktree, "status", "--porcelain").splitlines(),
[" M keep.txt", "?? junk"],
)
class DownloadPatchSetTests(unittest.TestCase):
"""Tests for Project.DownloadPatchSet."""
def setUp(self) -> None:
self.proj = mock.MagicMock()
self.proj.GetRevisionId.return_value = "base_rev_123"
self.proj.bare_git.rev_parse.return_value = "commit_sha_456"
mock_remote = mock.MagicMock()
mock_remote.name = "origin"
self.proj.GetRemote.return_value = mock_remote
def test_download_patch_set_with_no_filter_and_no_tags(self) -> None:
"""Test DownloadPatchSet includes --no-filter and --no-tags."""
with mock.patch(
"project.git_require", return_value=True
) as mock_git_require:
with mock.patch("project.GitCommand") as mock_git_cmd:
mock_cmd_instance = mock.MagicMock()
mock_cmd_instance.Wait.return_value = 0
mock_git_cmd.return_value = mock_cmd_instance
dl = project.Project.DownloadPatchSet(self.proj, 12345, 2)
mock_git_require.assert_called_once_with((2, 17, 0))
mock_git_cmd.assert_called_once_with(
self.proj,
[
"fetch",
"--no-filter",
"--no-tags",
"origin",
"refs/changes/45/12345/2",
],
bare=True,
verify_command=True,
)
self.assertEqual(dl.change_id, 12345)
self.assertEqual(dl.ps_id, 2)
self.assertEqual(dl.commit, "commit_sha_456")
def test_download_patch_set_legacy_git_omits_no_filter(self) -> None:
"""Test DownloadPatchSet omits --no-filter on git < 2.17."""
with mock.patch("project.git_require", return_value=False):
with mock.patch("project.GitCommand") as mock_git_cmd:
mock_cmd_instance = mock.MagicMock()
mock_cmd_instance.Wait.return_value = 0
mock_git_cmd.return_value = mock_cmd_instance
dl = project.Project.DownloadPatchSet(self.proj, 54321, 1)
mock_git_cmd.assert_called_once_with(
self.proj,
["fetch", "--no-tags", "origin", "refs/changes/21/54321/1"],
bare=True,
verify_command=True,
)
self.assertEqual(dl.change_id, 54321)
self.assertEqual(dl.ps_id, 1)