misc.python.materialize.git

Git utilities.

  1# Copyright Materialize, Inc. and contributors. All rights reserved.
  2#
  3# Use of this software is governed by the Business Source License
  4# included in the LICENSE file at the root of this repository.
  5#
  6# As of the Change Date specified in that file, in accordance with
  7# the Business Source License, use of this software will be governed
  8# by the Apache License, Version 2.0.
  9
 10"""Git utilities."""
 11
 12import functools
 13import os
 14import subprocess
 15import sys
 16from pathlib import Path
 17from typing import TypeVar
 18
 19import requests
 20
 21from materialize import spawn
 22from materialize.mz_version import MzVersion, TypedVersionBase
 23from materialize.util import YesNoOnce
 24
 25VERSION_TYPE = TypeVar("VERSION_TYPE", bound=TypedVersionBase)
 26
 27MATERIALIZE_REMOTE_URL = "https://github.com/MaterializeInc/materialize"
 28
 29fetched_tags_in_remotes: set[str | None] = set()
 30
 31
 32def get_config(key: str) -> str | None:
 33    """Read a git config value, returning None if unset."""
 34    try:
 35        return spawn.capture(["git", "config", key]).strip()
 36    except subprocess.CalledProcessError:
 37        return None
 38
 39
 40def get_user_name() -> str | None:
 41    """Get the configured git user.name."""
 42    return get_config("user.name")
 43
 44
 45def get_user_email() -> str | None:
 46    """Get the configured git user.email."""
 47    return get_config("user.email")
 48
 49
 50def rev_count(rev: str) -> int:
 51    """Count the commits up to a revision.
 52
 53    Args:
 54        rev: A Git revision in any format know to the Git CLI.
 55
 56    Returns:
 57        count: The number of commits in the Git repository starting from the
 58            initial commit and ending with the specified commit, inclusive.
 59    """
 60    return int(spawn.capture(["git", "rev-list", "--count", rev, "--"]).strip())
 61
 62
 63def get_first_parent_commits(rev: str, limit: int) -> list[str]:
 64    """Get commit hashes along the first-parent chain starting from rev.
 65
 66    Returns up to `limit` commit hashes (including rev itself), following
 67    only first parents (i.e., staying on the main branch).
 68    """
 69    return (
 70        spawn.capture(["git", "rev-list", "--first-parent", f"-{limit}", rev])
 71        .strip()
 72        .splitlines()
 73    )
 74
 75
 76def rev_parse(rev: str, *, abbrev: bool = False) -> str:
 77    """Compute the hash for a revision.
 78
 79    Args:
 80        rev: A Git revision in any format known to the Git CLI.
 81        abbrev: Return a branch or tag name instead of a git sha
 82
 83    Returns:
 84        ref: A 40 character hex-encoded SHA-1 hash representing the ID of the
 85            named revision in Git's object database.
 86
 87            With "abbrev=True" this will return an abbreviated ref, or throw an
 88            error if there is no abbrev.
 89    """
 90    a = ["--abbrev-ref"] if abbrev else []
 91    out = spawn.capture(["git", "rev-parse", *a, "--verify", rev]).strip()
 92    if not out:
 93        raise RuntimeError(f"No parsed rev for {rev}")
 94    return out
 95
 96
 97@functools.cache
 98def expand_globs(root: Path, *specs: Path | str) -> set[str]:
 99    """Find unignored files within the specified paths."""
100    # The goal here is to find all files in the working tree that are not
101    # ignored by .gitignore. Naively using `git ls-files` doesn't work, because
102    # it reports files that have been deleted in the working tree if they are
103    # still present in the index. Using `os.walkdir` doesn't work because there
104    # is no good way to evaluate .gitignore rules from Python. So we use a
105    # combination of `git diff` and `git ls-files`.
106
107    # `git diff` against the empty tree surfaces all tracked files that have
108    # not been deleted.
109    empty_tree = (
110        "4b825dc642cb6eb9a060e54bf8d69288fbee4904"  # git hash-object -t tree /dev/null
111    )
112    diff_files = spawn.capture(
113        ["git", "diff", "--name-only", "-z", "--relative", empty_tree, "--", *specs],
114        cwd=root,
115    )
116
117    # `git ls-files --others --exclude-standard` surfaces any non-ignored,
118    # untracked files, which are not included in the `git diff` output above.
119    ls_files = spawn.capture(
120        ["git", "ls-files", "--others", "--exclude-standard", "-z", "--", *specs],
121        cwd=root,
122    )
123
124    return set(f for f in (diff_files + ls_files).split("\0") if f.strip() != "")
125
126
127def get_version_tags(
128    *,
129    version_type: type[VERSION_TYPE],
130    newest_first: bool = True,
131    fetch: bool = True,
132    remote_url: str = MATERIALIZE_REMOTE_URL,
133) -> list[VERSION_TYPE]:
134    """List all the version-like tags in the repo
135
136    Args:
137        fetch: If false, don't automatically run `git fetch --tags`.
138        prefix: A prefix to strip from each tag before attempting to parse the
139            tag as a version.
140    """
141    if fetch:
142        _fetch(
143            remote=get_remote(remote_url),
144            include_tags=YesNoOnce.ONCE,
145            force=True,
146            only_tags=True,
147        )
148    tags = []
149    for t in spawn.capture(["git", "tag"]).splitlines():
150        if not t.startswith(version_type.get_prefix()):
151            continue
152        try:
153            tags.append(version_type.parse(t))
154        except ValueError as e:
155            print(f"WARN: {e}", file=sys.stderr)
156
157    return sorted(tags, reverse=newest_first)
158
159
160def get_latest_version(
161    version_type: type[VERSION_TYPE],
162    excluded_versions: set[VERSION_TYPE] | None = None,
163    current_version: VERSION_TYPE | None = None,
164) -> VERSION_TYPE:
165    all_version_tags: list[VERSION_TYPE] = get_version_tags(
166        version_type=version_type, fetch=True
167    )
168
169    if excluded_versions is not None:
170        all_version_tags = [
171            v
172            for v in all_version_tags
173            if v not in excluded_versions
174            and (not current_version or v < current_version)
175        ]
176
177    return max(all_version_tags)
178
179
180def get_tags_of_current_commit(include_tags: YesNoOnce = YesNoOnce.ONCE) -> list[str]:
181    if include_tags:
182        fetch(get_remote(), include_tags=include_tags, only_tags=True)
183
184    result = spawn.capture(["git", "tag", "--points-at", "HEAD"])
185
186    if len(result) == 0:
187        return []
188
189    return result.splitlines()
190
191
192def is_ancestor(earlier: str, later: str) -> bool:
193    """True if earlier is in an ancestor of later"""
194    try:
195        headers = {"Accept": "application/vnd.github+json"}
196        if token := os.getenv("GITHUB_TOKEN"):
197            headers["Authorization"] = f"Bearer {token}"
198
199        # GitHub resolves the ref "HEAD" server-side to the repo's default
200        # branch, not the local checkout's HEAD. Resolve it to a concrete SHA
201        # first, otherwise e.g. compare/HEAD...main becomes main...main.
202        api_earlier = rev_parse(earlier) if earlier == "HEAD" else earlier
203        api_later = rev_parse(later) if later == "HEAD" else later
204
205        resp = requests.get(
206            f"https://api.github.com/repos/materializeinc/materialize/compare/{api_earlier}...{api_later}",
207            headers=headers,
208        )
209        resp.raise_for_status()
210        data = resp.json()
211        return data.get("status") in ("ahead", "identical")
212    except Exception as e:
213        # Try locally if Github is down or the change has not been pushed yet when running locally
214        print(f"Failed to get ancestor status from Github, running locally: {e}")
215
216        # Make sure we have an up to date view of main.
217        command = ["git", "fetch"]
218        if (
219            spawn.capture(["git", "rev-parse", "--is-shallow-repository"]).strip()
220            == "true"
221        ):
222            command.append("--unshallow")
223        spawn.runv(command + [get_remote(), earlier, later])
224
225        return (
226            spawn.run_and_get_return_code(
227                ["git", "merge-base", "--is-ancestor", earlier, later]
228            )
229            == 0
230        )
231
232
233def is_dirty() -> bool:
234    """Check if the working directory has modifications to tracked files"""
235    proc = subprocess.run("git diff --no-ext-diff --quiet --exit-code".split())
236    idx = subprocess.run("git diff --cached --no-ext-diff --quiet --exit-code".split())
237    return proc.returncode != 0 or idx.returncode != 0
238
239
240def describe() -> str:
241    """Describe the relationship between the current commit and the most recent tag"""
242    return spawn.capture(["git", "describe"]).strip()
243
244
245def fetch(
246    remote: str | None = None,
247    all_remotes: bool = False,
248    include_tags: YesNoOnce = YesNoOnce.NO,
249    force: bool = False,
250    branch: str | None = None,
251    only_tags: bool = False,
252) -> str:
253    """Fetch from remotes"""
254
255    if remote is not None and all_remotes:
256        raise RuntimeError("all_remotes must be false when a remote is specified")
257
258    if branch is not None and remote is None:
259        raise RuntimeError("remote must be specified when a branch is specified")
260
261    if branch is not None and only_tags:
262        raise RuntimeError("branch must not be specified if only_tags is set")
263
264    command = ["git", "fetch"]
265    if spawn.capture(["git", "rev-parse", "--is-shallow-repository"]).strip() == "true":
266        command.append("--unshallow")
267
268    if remote:
269        command.append(remote)
270
271    if branch:
272        command.append(branch)
273
274    if all_remotes:
275        command.append("--all")
276
277    fetch_tags = (
278        include_tags == YesNoOnce.YES
279        # fetch tags again if used with force (tags might have changed)
280        or (include_tags == YesNoOnce.ONCE and force)
281        or (
282            include_tags == YesNoOnce.ONCE
283            and remote not in fetched_tags_in_remotes
284            and "*" not in fetched_tags_in_remotes
285        )
286    )
287
288    if fetch_tags:
289        command.append("--tags")
290
291    if force:
292        command.append("--force")
293
294    if not fetch_tags and only_tags:
295        return ""
296
297    output = spawn.capture(command).strip()
298
299    if fetch_tags:
300        fetched_tags_in_remotes.add(remote)
301
302        if all_remotes:
303            fetched_tags_in_remotes.add("*")
304
305    return output
306
307
308_fetch = fetch  # renamed because an argument shadows the fetch name in get_tags
309
310
311def try_get_remote_name_by_url(url: str) -> str | None:
312    result = spawn.capture(["git", "remote", "--verbose"])
313    for line in result.splitlines():
314        remote, desc = line.split("\t")
315        if desc.lower() in (f"{url} (fetch)".lower(), f"{url}.git (fetch)".lower()):
316            return remote
317    return None
318
319
320def get_remote(
321    url: str = MATERIALIZE_REMOTE_URL,
322    default_remote_name: str = "origin",
323) -> str:
324    # Alternative syntax
325    remote = try_get_remote_name_by_url(url) or try_get_remote_name_by_url(
326        url.replace("https://github.com/", "git@github.com:")
327    )
328    if not remote:
329        remote = default_remote_name
330        print(f"Remote for URL {url} not found, using {remote}")
331
332    return remote
333
334
335def get_common_ancestor_commit(remote: str, branch: str) -> str:
336    try:
337        head = spawn.capture(["git", "rev-parse", "HEAD"]).strip()
338        headers = {"Accept": "application/vnd.github+json"}
339        if token := os.getenv("GITHUB_TOKEN"):
340            headers["Authorization"] = f"Bearer {token}"
341
342        resp = requests.get(
343            f"https://api.github.com/repos/materializeinc/materialize/compare/{head}...{branch}",
344            headers=headers,
345        )
346        resp.raise_for_status()
347        data = resp.json()
348        return data["merge_base_commit"]["sha"]
349    except Exception as e:
350        # Try locally if Github is down or the change has not been pushed yet when running locally
351        print(f"Failed to get ancestor commit from Github, running locally: {e}")
352
353        # Make sure we have an up to date view
354        command = ["git", "fetch"]
355        if (
356            spawn.capture(["git", "rev-parse", "--is-shallow-repository"]).strip()
357            == "true"
358        ):
359            command.append("--unshallow")
360        spawn.runv(command + [remote, branch])
361
362        return spawn.capture(
363            ["git", "merge-base", "HEAD", f"{remote}/{branch}"]
364        ).strip()
365
366
367def is_on_release_version() -> bool:
368    git_tags = get_tags_of_current_commit()
369    return any(MzVersion.is_valid_version_string(git_tag) for git_tag in git_tags)
370
371
372def contains_commit(
373    commit_sha: str,
374    target: str = "HEAD",
375    remote_url: str = MATERIALIZE_REMOTE_URL,
376) -> bool:
377    return is_ancestor(commit_sha, target)
378
379
380def get_tagged_release_version(version_type: type[VERSION_TYPE]) -> VERSION_TYPE | None:
381    """
382    This returns the release version if exactly this commit is tagged.
383    If multiple release versions are present, the highest one will be returned.
384    None will be returned if the commit is not tagged.
385    """
386    git_tags = get_tags_of_current_commit()
387
388    versions: list[VERSION_TYPE] = []
389
390    for git_tag in git_tags:
391        if version_type.is_valid_version_string(git_tag):
392            versions.append(version_type.parse(git_tag))
393
394    if len(versions) == 0:
395        return None
396
397    if len(versions) > 1:
398        print(
399            "Warning! Commit is tagged with multiple release versions! Returning the highest."
400        )
401
402    return max(versions)
403
404
405def get_commit_message(commit_sha: str) -> str | None:
406    try:
407        command = ["git", "log", "-1", "--pretty=format:%s", commit_sha]
408        return spawn.capture(command, stderr=subprocess.DEVNULL).strip()
409    except subprocess.CalledProcessError:
410        # Sometimes mz_version() will report a Git SHA that is not available
411        # in the current repository
412        return None
413
414
415def get_branch_name() -> str:
416    """This may not work on Buildkite; consider using the same function from build_context."""
417    command = ["git", "branch", "--show-current"]
418    return spawn.capture(command).strip()
419
420
421# Work tree mutation
422
423
424def create_branch(name: str) -> None:
425    spawn.runv(["git", "checkout", "-b", name])
426
427
428def checkout(rev: str, path: str | None = None) -> None:
429    """Git checkout the rev"""
430    cmd = ["git", "checkout", rev]
431    if path:
432        cmd.extend(["--", path])
433    spawn.runv(cmd)
434
435
436def add_file(file: str) -> None:
437    """Git add a file"""
438    spawn.runv(["git", "add", file])
439
440
441def commit_all_changed(message: str) -> None:
442    """Commit all changed files with the given message"""
443    spawn.runv(["git", "commit", "-a", "-m", message])
444
445
446def tag_annotated(tag: str) -> None:
447    """Create an annotated tag on HEAD"""
448    spawn.runv(["git", "tag", "-a", "-m", tag, tag])
MATERIALIZE_REMOTE_URL = 'https://github.com/MaterializeInc/materialize'
fetched_tags_in_remotes: set[str | None] = set()
def get_config(key: str) -> str | None:
33def get_config(key: str) -> str | None:
34    """Read a git config value, returning None if unset."""
35    try:
36        return spawn.capture(["git", "config", key]).strip()
37    except subprocess.CalledProcessError:
38        return None

Read a git config value, returning None if unset.

def get_user_name() -> str | None:
41def get_user_name() -> str | None:
42    """Get the configured git user.name."""
43    return get_config("user.name")

Get the configured git user.name.

def get_user_email() -> str | None:
46def get_user_email() -> str | None:
47    """Get the configured git user.email."""
48    return get_config("user.email")

Get the configured git user.email.

def rev_count(rev: str) -> int:
51def rev_count(rev: str) -> int:
52    """Count the commits up to a revision.
53
54    Args:
55        rev: A Git revision in any format know to the Git CLI.
56
57    Returns:
58        count: The number of commits in the Git repository starting from the
59            initial commit and ending with the specified commit, inclusive.
60    """
61    return int(spawn.capture(["git", "rev-list", "--count", rev, "--"]).strip())

Count the commits up to a revision.

Args: rev: A Git revision in any format know to the Git CLI.

Returns: count: The number of commits in the Git repository starting from the initial commit and ending with the specified commit, inclusive.

def get_first_parent_commits(rev: str, limit: int) -> list[str]:
64def get_first_parent_commits(rev: str, limit: int) -> list[str]:
65    """Get commit hashes along the first-parent chain starting from rev.
66
67    Returns up to `limit` commit hashes (including rev itself), following
68    only first parents (i.e., staying on the main branch).
69    """
70    return (
71        spawn.capture(["git", "rev-list", "--first-parent", f"-{limit}", rev])
72        .strip()
73        .splitlines()
74    )

Get commit hashes along the first-parent chain starting from rev.

Returns up to limit commit hashes (including rev itself), following only first parents (i.e., staying on the main branch).

def rev_parse(rev: str, *, abbrev: bool = False) -> str:
77def rev_parse(rev: str, *, abbrev: bool = False) -> str:
78    """Compute the hash for a revision.
79
80    Args:
81        rev: A Git revision in any format known to the Git CLI.
82        abbrev: Return a branch or tag name instead of a git sha
83
84    Returns:
85        ref: A 40 character hex-encoded SHA-1 hash representing the ID of the
86            named revision in Git's object database.
87
88            With "abbrev=True" this will return an abbreviated ref, or throw an
89            error if there is no abbrev.
90    """
91    a = ["--abbrev-ref"] if abbrev else []
92    out = spawn.capture(["git", "rev-parse", *a, "--verify", rev]).strip()
93    if not out:
94        raise RuntimeError(f"No parsed rev for {rev}")
95    return out

Compute the hash for a revision.

Args: rev: A Git revision in any format known to the Git CLI. abbrev: Return a branch or tag name instead of a git sha

Returns: ref: A 40 character hex-encoded SHA-1 hash representing the ID of the named revision in Git's object database.

    With "abbrev=True" this will return an abbreviated ref, or throw an
    error if there is no abbrev.
@functools.cache
def expand_globs(root: pathlib._local.Path, *specs: pathlib._local.Path | str) -> set[str]:
 98@functools.cache
 99def expand_globs(root: Path, *specs: Path | str) -> set[str]:
100    """Find unignored files within the specified paths."""
101    # The goal here is to find all files in the working tree that are not
102    # ignored by .gitignore. Naively using `git ls-files` doesn't work, because
103    # it reports files that have been deleted in the working tree if they are
104    # still present in the index. Using `os.walkdir` doesn't work because there
105    # is no good way to evaluate .gitignore rules from Python. So we use a
106    # combination of `git diff` and `git ls-files`.
107
108    # `git diff` against the empty tree surfaces all tracked files that have
109    # not been deleted.
110    empty_tree = (
111        "4b825dc642cb6eb9a060e54bf8d69288fbee4904"  # git hash-object -t tree /dev/null
112    )
113    diff_files = spawn.capture(
114        ["git", "diff", "--name-only", "-z", "--relative", empty_tree, "--", *specs],
115        cwd=root,
116    )
117
118    # `git ls-files --others --exclude-standard` surfaces any non-ignored,
119    # untracked files, which are not included in the `git diff` output above.
120    ls_files = spawn.capture(
121        ["git", "ls-files", "--others", "--exclude-standard", "-z", "--", *specs],
122        cwd=root,
123    )
124
125    return set(f for f in (diff_files + ls_files).split("\0") if f.strip() != "")

Find unignored files within the specified paths.

def get_version_tags( *, version_type: type[~VERSION_TYPE], newest_first: bool = True, fetch: bool = True, remote_url: str = 'https://github.com/MaterializeInc/materialize') -> list[~VERSION_TYPE]:
128def get_version_tags(
129    *,
130    version_type: type[VERSION_TYPE],
131    newest_first: bool = True,
132    fetch: bool = True,
133    remote_url: str = MATERIALIZE_REMOTE_URL,
134) -> list[VERSION_TYPE]:
135    """List all the version-like tags in the repo
136
137    Args:
138        fetch: If false, don't automatically run `git fetch --tags`.
139        prefix: A prefix to strip from each tag before attempting to parse the
140            tag as a version.
141    """
142    if fetch:
143        _fetch(
144            remote=get_remote(remote_url),
145            include_tags=YesNoOnce.ONCE,
146            force=True,
147            only_tags=True,
148        )
149    tags = []
150    for t in spawn.capture(["git", "tag"]).splitlines():
151        if not t.startswith(version_type.get_prefix()):
152            continue
153        try:
154            tags.append(version_type.parse(t))
155        except ValueError as e:
156            print(f"WARN: {e}", file=sys.stderr)
157
158    return sorted(tags, reverse=newest_first)

List all the version-like tags in the repo

Args: fetch: If false, don't automatically run git fetch --tags. prefix: A prefix to strip from each tag before attempting to parse the tag as a version.

def get_latest_version( version_type: type[~VERSION_TYPE], excluded_versions: set[~VERSION_TYPE] | None = None, current_version: Optional[~VERSION_TYPE] = None) -> ~VERSION_TYPE:
161def get_latest_version(
162    version_type: type[VERSION_TYPE],
163    excluded_versions: set[VERSION_TYPE] | None = None,
164    current_version: VERSION_TYPE | None = None,
165) -> VERSION_TYPE:
166    all_version_tags: list[VERSION_TYPE] = get_version_tags(
167        version_type=version_type, fetch=True
168    )
169
170    if excluded_versions is not None:
171        all_version_tags = [
172            v
173            for v in all_version_tags
174            if v not in excluded_versions
175            and (not current_version or v < current_version)
176        ]
177
178    return max(all_version_tags)
def get_tags_of_current_commit( include_tags: materialize.util.YesNoOnce = <YesNoOnce.ONCE: 3>) -> list[str]:
181def get_tags_of_current_commit(include_tags: YesNoOnce = YesNoOnce.ONCE) -> list[str]:
182    if include_tags:
183        fetch(get_remote(), include_tags=include_tags, only_tags=True)
184
185    result = spawn.capture(["git", "tag", "--points-at", "HEAD"])
186
187    if len(result) == 0:
188        return []
189
190    return result.splitlines()
def is_ancestor(earlier: str, later: str) -> bool:
193def is_ancestor(earlier: str, later: str) -> bool:
194    """True if earlier is in an ancestor of later"""
195    try:
196        headers = {"Accept": "application/vnd.github+json"}
197        if token := os.getenv("GITHUB_TOKEN"):
198            headers["Authorization"] = f"Bearer {token}"
199
200        # GitHub resolves the ref "HEAD" server-side to the repo's default
201        # branch, not the local checkout's HEAD. Resolve it to a concrete SHA
202        # first, otherwise e.g. compare/HEAD...main becomes main...main.
203        api_earlier = rev_parse(earlier) if earlier == "HEAD" else earlier
204        api_later = rev_parse(later) if later == "HEAD" else later
205
206        resp = requests.get(
207            f"https://api.github.com/repos/materializeinc/materialize/compare/{api_earlier}...{api_later}",
208            headers=headers,
209        )
210        resp.raise_for_status()
211        data = resp.json()
212        return data.get("status") in ("ahead", "identical")
213    except Exception as e:
214        # Try locally if Github is down or the change has not been pushed yet when running locally
215        print(f"Failed to get ancestor status from Github, running locally: {e}")
216
217        # Make sure we have an up to date view of main.
218        command = ["git", "fetch"]
219        if (
220            spawn.capture(["git", "rev-parse", "--is-shallow-repository"]).strip()
221            == "true"
222        ):
223            command.append("--unshallow")
224        spawn.runv(command + [get_remote(), earlier, later])
225
226        return (
227            spawn.run_and_get_return_code(
228                ["git", "merge-base", "--is-ancestor", earlier, later]
229            )
230            == 0
231        )

True if earlier is in an ancestor of later

def is_dirty() -> bool:
234def is_dirty() -> bool:
235    """Check if the working directory has modifications to tracked files"""
236    proc = subprocess.run("git diff --no-ext-diff --quiet --exit-code".split())
237    idx = subprocess.run("git diff --cached --no-ext-diff --quiet --exit-code".split())
238    return proc.returncode != 0 or idx.returncode != 0

Check if the working directory has modifications to tracked files

def describe() -> str:
241def describe() -> str:
242    """Describe the relationship between the current commit and the most recent tag"""
243    return spawn.capture(["git", "describe"]).strip()

Describe the relationship between the current commit and the most recent tag

def fetch( remote: str | None = None, all_remotes: bool = False, include_tags: materialize.util.YesNoOnce = <YesNoOnce.NO: 2>, force: bool = False, branch: str | None = None, only_tags: bool = False) -> str:
246def fetch(
247    remote: str | None = None,
248    all_remotes: bool = False,
249    include_tags: YesNoOnce = YesNoOnce.NO,
250    force: bool = False,
251    branch: str | None = None,
252    only_tags: bool = False,
253) -> str:
254    """Fetch from remotes"""
255
256    if remote is not None and all_remotes:
257        raise RuntimeError("all_remotes must be false when a remote is specified")
258
259    if branch is not None and remote is None:
260        raise RuntimeError("remote must be specified when a branch is specified")
261
262    if branch is not None and only_tags:
263        raise RuntimeError("branch must not be specified if only_tags is set")
264
265    command = ["git", "fetch"]
266    if spawn.capture(["git", "rev-parse", "--is-shallow-repository"]).strip() == "true":
267        command.append("--unshallow")
268
269    if remote:
270        command.append(remote)
271
272    if branch:
273        command.append(branch)
274
275    if all_remotes:
276        command.append("--all")
277
278    fetch_tags = (
279        include_tags == YesNoOnce.YES
280        # fetch tags again if used with force (tags might have changed)
281        or (include_tags == YesNoOnce.ONCE and force)
282        or (
283            include_tags == YesNoOnce.ONCE
284            and remote not in fetched_tags_in_remotes
285            and "*" not in fetched_tags_in_remotes
286        )
287    )
288
289    if fetch_tags:
290        command.append("--tags")
291
292    if force:
293        command.append("--force")
294
295    if not fetch_tags and only_tags:
296        return ""
297
298    output = spawn.capture(command).strip()
299
300    if fetch_tags:
301        fetched_tags_in_remotes.add(remote)
302
303        if all_remotes:
304            fetched_tags_in_remotes.add("*")
305
306    return output

Fetch from remotes

def try_get_remote_name_by_url(url: str) -> str | None:
312def try_get_remote_name_by_url(url: str) -> str | None:
313    result = spawn.capture(["git", "remote", "--verbose"])
314    for line in result.splitlines():
315        remote, desc = line.split("\t")
316        if desc.lower() in (f"{url} (fetch)".lower(), f"{url}.git (fetch)".lower()):
317            return remote
318    return None
def get_remote( url: str = 'https://github.com/MaterializeInc/materialize', default_remote_name: str = 'origin') -> str:
321def get_remote(
322    url: str = MATERIALIZE_REMOTE_URL,
323    default_remote_name: str = "origin",
324) -> str:
325    # Alternative syntax
326    remote = try_get_remote_name_by_url(url) or try_get_remote_name_by_url(
327        url.replace("https://github.com/", "git@github.com:")
328    )
329    if not remote:
330        remote = default_remote_name
331        print(f"Remote for URL {url} not found, using {remote}")
332
333    return remote
def get_common_ancestor_commit(remote: str, branch: str) -> str:
336def get_common_ancestor_commit(remote: str, branch: str) -> str:
337    try:
338        head = spawn.capture(["git", "rev-parse", "HEAD"]).strip()
339        headers = {"Accept": "application/vnd.github+json"}
340        if token := os.getenv("GITHUB_TOKEN"):
341            headers["Authorization"] = f"Bearer {token}"
342
343        resp = requests.get(
344            f"https://api.github.com/repos/materializeinc/materialize/compare/{head}...{branch}",
345            headers=headers,
346        )
347        resp.raise_for_status()
348        data = resp.json()
349        return data["merge_base_commit"]["sha"]
350    except Exception as e:
351        # Try locally if Github is down or the change has not been pushed yet when running locally
352        print(f"Failed to get ancestor commit from Github, running locally: {e}")
353
354        # Make sure we have an up to date view
355        command = ["git", "fetch"]
356        if (
357            spawn.capture(["git", "rev-parse", "--is-shallow-repository"]).strip()
358            == "true"
359        ):
360            command.append("--unshallow")
361        spawn.runv(command + [remote, branch])
362
363        return spawn.capture(
364            ["git", "merge-base", "HEAD", f"{remote}/{branch}"]
365        ).strip()
def is_on_release_version() -> bool:
368def is_on_release_version() -> bool:
369    git_tags = get_tags_of_current_commit()
370    return any(MzVersion.is_valid_version_string(git_tag) for git_tag in git_tags)
def contains_commit( commit_sha: str, target: str = 'HEAD', remote_url: str = 'https://github.com/MaterializeInc/materialize') -> bool:
373def contains_commit(
374    commit_sha: str,
375    target: str = "HEAD",
376    remote_url: str = MATERIALIZE_REMOTE_URL,
377) -> bool:
378    return is_ancestor(commit_sha, target)
def get_tagged_release_version(version_type: type[~VERSION_TYPE]) -> Optional[~VERSION_TYPE]:
381def get_tagged_release_version(version_type: type[VERSION_TYPE]) -> VERSION_TYPE | None:
382    """
383    This returns the release version if exactly this commit is tagged.
384    If multiple release versions are present, the highest one will be returned.
385    None will be returned if the commit is not tagged.
386    """
387    git_tags = get_tags_of_current_commit()
388
389    versions: list[VERSION_TYPE] = []
390
391    for git_tag in git_tags:
392        if version_type.is_valid_version_string(git_tag):
393            versions.append(version_type.parse(git_tag))
394
395    if len(versions) == 0:
396        return None
397
398    if len(versions) > 1:
399        print(
400            "Warning! Commit is tagged with multiple release versions! Returning the highest."
401        )
402
403    return max(versions)

This returns the release version if exactly this commit is tagged. If multiple release versions are present, the highest one will be returned. None will be returned if the commit is not tagged.

def get_commit_message(commit_sha: str) -> str | None:
406def get_commit_message(commit_sha: str) -> str | None:
407    try:
408        command = ["git", "log", "-1", "--pretty=format:%s", commit_sha]
409        return spawn.capture(command, stderr=subprocess.DEVNULL).strip()
410    except subprocess.CalledProcessError:
411        # Sometimes mz_version() will report a Git SHA that is not available
412        # in the current repository
413        return None
def get_branch_name() -> str:
416def get_branch_name() -> str:
417    """This may not work on Buildkite; consider using the same function from build_context."""
418    command = ["git", "branch", "--show-current"]
419    return spawn.capture(command).strip()

This may not work on Buildkite; consider using the same function from build_context.

def create_branch(name: str) -> None:
425def create_branch(name: str) -> None:
426    spawn.runv(["git", "checkout", "-b", name])
def checkout(rev: str, path: str | None = None) -> None:
429def checkout(rev: str, path: str | None = None) -> None:
430    """Git checkout the rev"""
431    cmd = ["git", "checkout", rev]
432    if path:
433        cmd.extend(["--", path])
434    spawn.runv(cmd)

Git checkout the rev

def add_file(file: str) -> None:
437def add_file(file: str) -> None:
438    """Git add a file"""
439    spawn.runv(["git", "add", file])

Git add a file

def commit_all_changed(message: str) -> None:
442def commit_all_changed(message: str) -> None:
443    """Commit all changed files with the given message"""
444    spawn.runv(["git", "commit", "-a", "-m", message])

Commit all changed files with the given message

def tag_annotated(tag: str) -> None:
447def tag_annotated(tag: str) -> None:
448    """Create an annotated tag on HEAD"""
449    spawn.runv(["git", "tag", "-a", "-m", tag, tag])

Create an annotated tag on HEAD