diff --git a/src/dvsim/flow/base.py b/src/dvsim/flow/base.py index 4345a31b..497e8a42 100644 --- a/src/dvsim/flow/base.py +++ b/src/dvsim/flow/base.py @@ -32,7 +32,7 @@ rm_path, subst_wildcards, ) -from dvsim.utils.git import git_commit_hash +from dvsim.utils.git import git_commit_hash, git_is_dirty if TYPE_CHECKING: from dvsim.job.deploy import Deploy @@ -173,6 +173,7 @@ def __init__(self, flow_cfg_file, hjson_data, args, mk_config) -> None: proj_root = Path(self.proj_root) self.commit = git_commit_hash(path=proj_root, short=False) self.commit_short = git_commit_hash(path=proj_root, short=True) + self.dirty = git_is_dirty(path=proj_root) # Construct the path variables after variable expansion. reports_dir = Path(self.scratch_base_path) / "reports" diff --git a/src/dvsim/report/data.py b/src/dvsim/report/data.py index 95ae6b20..e385d204 100644 --- a/src/dvsim/report/data.py +++ b/src/dvsim/report/data.py @@ -26,10 +26,13 @@ class IPMeta(BaseModel): """Git commit sha of the IP the tests are run against.""" commit_short: str """Shortened Git commit sha of the IP the tests are run against.""" + dirty: bool = False + """Whether the working tree had uncommitted changes at run time.""" branch: str """Git branch""" - url: str - """URL to where the IP can be found in git (e.g. github).""" + url: str | None + """URL to where the IP can be found in git (e.g. github). None if the local + checkout has no ``origin`` remote (typical for developer clones).""" revision_info: str | None """Optional revision info string to use for custom info instead of the above fields.""" diff --git a/src/dvsim/sim/flow.py b/src/dvsim/sim/flow.py index 8c8536b6..6dc400c4 100644 --- a/src/dvsim/sim/flow.py +++ b/src/dvsim/sim/flow.py @@ -607,6 +607,11 @@ def gen_results(self, results: Sequence[CompletedJobStatus]) -> None: repo_root = Path(self.proj_root) reports_dir = Path(self.scratch_base_path) / "reports" url = git_https_url_with_commit(path=repo_root) + if url is None: + log.debug( + "no 'origin' remote in %s — report will not include an upstream source link", + repo_root, + ) build_seed = self.build_seed if not self.run_only else None try: @@ -657,6 +662,7 @@ def gen_results(self, results: Sequence[CompletedJobStatus]) -> None: variant=self.variant, commit=self.commit, commit_short=self.commit_short, + dirty=self.dirty, branch=self.branch, url=url, revision_info=self.revision, @@ -705,6 +711,7 @@ def _gen_json_results( variant=(self.variant or "").lower() or None, commit=self.commit, commit_short=self.commit_short, + dirty=self.dirty, branch=self.branch or "", url=url, revision_info=self.revision, diff --git a/src/dvsim/sim/report.py b/src/dvsim/sim/report.py index eba04395..bdda6311 100644 --- a/src/dvsim/sim/report.py +++ b/src/dvsim/sim/report.py @@ -243,7 +243,12 @@ def render_metadata( revision = (scope.revision_info or "").strip() if not revision: - revision = f"Github Revision: [`{scope.commit_short}`]({scope.url})" + if scope.url: + revision = f"Github Revision: [`{scope.commit_short}`]({scope.url})" + else: + revision = f"Revision: `{scope.commit_short}`" + if scope.dirty and "(dirty)" not in revision: + revision += " (dirty)" report_md += f"\n### {revision}" report_md += f"\n### Branch: {scope.branch}" diff --git a/src/dvsim/utils/git.py b/src/dvsim/utils/git.py index 996a17cf..ce537bdf 100644 --- a/src/dvsim/utils/git.py +++ b/src/dvsim/utils/git.py @@ -44,8 +44,19 @@ def git_commit_hash(path: Path | None = None, *, short: bool = False) -> str: return r.head.commit.hexsha -def git_origin_url(path: Path | None = None) -> str: - """Get the git remote origin url.""" +def git_is_dirty(path: Path | None = None) -> bool: + """Return True if the working tree has uncommitted changes to tracked files.""" + root = repo_root(path=path or Path.cwd()) + + if root is None: + log.error("no git repo found at %s", path) + raise ValueError + + return Repo(root).is_dirty() + + +def git_origin_url(path: Path | None = None) -> str | None: + """Get the git remote origin url, or None if no ``origin`` remote is configured.""" root = repo_root(path=path or Path.cwd()) if root is None: @@ -54,20 +65,31 @@ def git_origin_url(path: Path | None = None) -> str: r = Repo(root) - return r.remote().url + if "origin" not in [remote.name for remote in r.remotes]: + return None + + return r.remote("origin").url -def git_https_url_with_commit(path: Path | None = None) -> str: +def git_https_url_with_commit(path: Path | None = None) -> str | None: """Get an https url that references the current commit. + The link is derived from the ``origin`` remote, which in CI/regression + workflows points at the canonical upstream repository. In developer + checkouts where ``origin`` is absent or points elsewhere, return ``None`` + rather than guessing — a wrong link is worse than none. + Args: path: the path to the git repo Returns: - str containing the https url + str containing the https url, or None if no ``origin`` remote exists. """ url = git_origin_url(path=path) + if url is None: + return None + commit = git_commit_hash(path=path) url = url.removesuffix(".git") diff --git a/tests/utils/test_git.py b/tests/utils/test_git.py index 30e35689..06bde5ce 100644 --- a/tests/utils/test_git.py +++ b/tests/utils/test_git.py @@ -10,7 +10,13 @@ from git import Repo from hamcrest import assert_that, calling, equal_to, raises -from dvsim.utils.git import git_commit_hash, git_https_url_with_commit, git_origin_url, repo_root +from dvsim.utils.git import ( + git_commit_hash, + git_https_url_with_commit, + git_is_dirty, + git_origin_url, + repo_root, +) __all__ = () @@ -73,11 +79,11 @@ def test_git_short_commit_hash(tmp_path: "Path") -> None: ) @staticmethod - def test_git_origin_url(tmp_path: Path) -> None: - """Test that the expected git remote origin url is returned.""" + def test_git_is_dirty(tmp_path: Path) -> None: + """Test that git_is_dirty reflects the working tree state.""" # Value error if called outside a git repo assert_that( - calling(git_origin_url).with_args(tmp_path), + calling(git_is_dirty).with_args(tmp_path), raises(ValueError), ) @@ -88,12 +94,36 @@ def test_git_origin_url(tmp_path: Path) -> None: r.index.add([file]) r.index.commit("initial commit") + # Clean tree + assert_that(git_is_dirty(tmp_path), equal_to(False)) + + # Modify a tracked file — now dirty + file.write_text("changed") + assert_that(git_is_dirty(tmp_path), equal_to(True)) + + @staticmethod + def test_git_origin_url(tmp_path: Path) -> None: + """Test that the expected git remote origin url is returned.""" # Value error if called outside a git repo assert_that( calling(git_origin_url).with_args(tmp_path), raises(ValueError), ) + r = Repo.init(path=tmp_path) + + file = tmp_path / "a" + file.write_text("file to commit") + r.index.add([file]) + r.index.commit("initial commit") + + # None if the repo has no 'origin' remote configured + assert_that(git_origin_url(tmp_path), equal_to(None)) + + # Still None if there are remotes but none named 'origin' + r.create_remote("upstream", "git@github.com:lowRISC/other.git") + assert_that(git_origin_url(tmp_path), equal_to(None)) + url = "git@github.com:lowRISC/test.git" r.create_remote("origin", url) @@ -122,6 +152,9 @@ def test_git_https_url_with_commit(tmp_path: Path, url: str, expected: str) -> N r.index.add([file]) r.index.commit("initial commit") + # Returns None when no 'origin' remote is configured. + assert_that(git_https_url_with_commit(tmp_path), equal_to(None)) + r.create_remote("origin", url) commit = r.head.commit.hexsha