cornsnake.util_git

Functions for interacting with a Git repository. It includes functions for executing Git commands, checking out branches, handling commits, and managing Git configuration settings.

Documentation

  1"""
  2Functions for interacting with a Git repository. It includes functions for executing Git commands, checking out branches, handling commits, and managing Git configuration settings.
  3
  4[Documentation](http://docs.mrseanryan.cornsnake.s3-website-eu-west-1.amazonaws.com/cornsnake/util_git.html)
  5"""
  6
  7import os
  8
  9from . import config
 10from . import util_date
 11from . import util_dir
 12from . import util_file
 13from . import util_list
 14from . import util_print
 15from . import util_log
 16from . import util_proc
 17
 18COMMIT_PARTS_SEPARTOR = "_||_"
 19
 20logger = util_log.getLogger(__name__)
 21
 22
 23def _parse_result(result: str) -> list[str]:
 24    """
 25    Function to parse the result obtained from executing Git commands.
 26
 27    Args:
 28    result (str): The result string to be parsed.
 29
 30    Returns:
 31    list: A list of cleaned parts obtained by splitting the result string.
 32    """
 33    parts = result.split(COMMIT_PARTS_SEPARTOR)
 34    parts_cleaned = []
 35    for part in parts:
 36        parts_cleaned.append(part.replace('"', ""))
 37    return parts_cleaned
 38
 39
 40def get_branches_with_commits_in_origin_not_local(
 41    repo_dir: str, branches: list[str]
 42) -> list[str]:
 43    """
 44    For all the given branches, find any missing commits: commits that are on origin but not this copy of the repository.
 45    """
 46    if not has_git_remote_urls(repo_dir):
 47        util_print.print_warning(
 48            f"Cannot check for missing commits: the local repo {repo_dir} has no remote origin"
 49        )
 50        return []
 51    branches_with_missing_commits = []
 52    fetch(repo_dir)
 53    for branch in branches:
 54        checkout_branch(branch, repo_dir)
 55        result = execute_command("rev-list", [f"HEAD..origin/{branch}"], repo_dir)
 56        if result.strip():
 57            branches_with_missing_commits.append(branch)
 58    return branches_with_missing_commits
 59
 60
 61def get_current_branch(local_repo_location: str) -> str:
 62    """
 63    Function to get the current branch of the Git repository.
 64
 65    Args:
 66    local_repo_location (str): The local repository location.
 67
 68    Returns:
 69    str: The name of the current branch.
 70    """
 71    return execute_command(
 72        "rev-parse", ["--abbrev-ref", "HEAD"], local_repo_location
 73    ).strip()
 74
 75
 76def get_last_commit_id(path_to_repo_dir: str, file_only: str | None = None) -> str:
 77    """Get the last commit ID based on the given path to the repository directory and an optional file parameter.
 78    Args:
 79    path_to_repo_dir (str): The path to the repository directory.
 80    file_only (str): Optional parameter to specify a specific file.
 81    Returns:
 82    str: The last commit ID."""
 83    # git log -n 1 --pretty=format:"%H_|\_%ad_|\_%s" --date=short BUILD.plz
 84    git_args = [
 85        "log",
 86        "-n",
 87        "1",
 88        f'--pretty=format:"%H{COMMIT_PARTS_SEPARTOR}%ad{COMMIT_PARTS_SEPARTOR}%s"',
 89        "--date=short",
 90    ]
 91    if file_only is not None:
 92        git_args += [file_only]
 93    result = util_proc.run_process_and_get_output(
 94        config.PATH_TO_GIT, git_args, path_to_repo_dir
 95    )
 96    if config.IS_VERBOSE:
 97        print("  " + result)
 98    parsed = _parse_result(result)
 99    commit = parsed[0]
100    return commit
101
102
103def has_git_remote_urls(repo_dir: str) -> bool:
104    """
105    Does the given git repository directory have any remote origin URLs.
106    """
107    result = execute_command("remote", ["-v"], repo_dir)
108    return len(result.strip()) > 0
109
110
111def is_git_repo(repo_dir: str) -> bool:
112    """
113    Is the given directory a git repository (contains .git folder)
114    """
115    path_to_git_config = os.path.join(repo_dir, ".git", "config")
116    return os.path.exists(path_to_git_config)
117
118
119def list_git_remote_urls(repo_dir: str) -> list[str]:
120    """
121    List any remote origin URLs of the given git repository directory.
122    """
123    if not has_git_remote_urls(repo_dir):
124        return []
125
126    remote_urls = []
127    # git remote -v
128    result = execute_command("remote", ["-v"], repo_dir)
129    lines = result.split("\n")
130
131    for line in lines:
132        if not line.strip():
133            continue
134        # example:
135        # origin  https://git.api.mendix.com/b7a238f5-07a8-4008-83a7-b98590f40969.git/ (fetch)
136        parts = line.split()
137        if len(parts) == 3:
138            remote_urls.append(parts[1])
139        else:
140            raise RuntimeError(f"Cannot parse output of git remote: {line}")
141
142    return remote_urls
143
144
145def get_git_remote_push_url(repo_dir: str) -> str:
146    """
147    Get the remote origin *push* URL of the given git repository directory.
148    """
149    try:
150        # git remote get-url --push origin
151        return execute_command(
152            "remote", ["get-url", "--push", "origin"], repo_dir
153        ).rstrip()
154    except Exception as e:
155        util_log.log_exception(e)
156        return ""
157
158
159def execute_command(command: str, git_args: list[str], working_dir: str) -> str:
160    """Execute a Git command with specified arguments in the given working directory.
161    Args:
162    command (str): The Git command to execute.
163    git_args (list): List of arguments for the Git command.
164    working_dir (str): The working directory to execute the command in.
165    Returns:
166    str: The output of the command."""
167    git_args_with_command = [command] + git_args
168    return util_proc.run_process_and_get_output(
169        config.PATH_TO_GIT, git_args_with_command, working_dir
170    )
171
172
173# execute git command with too many parameters - so we chunk
174def execute_command_in_chunks(
175    command: str,
176    git_args: list[str],
177    git_extra_args_to_chunk: list[str],
178    working_dir: str,
179) -> None:
180    """Execute a Git command with arguments provided in chunks to avoid exceeding the parameter limit.
181    Args:
182    command (str): The Git command to execute.
183    git_args (list): List of arguments for the Git command.
184    git_extra_args_to_chunk (list): List of additional arguments to chunk and execute.
185    working_dir (str): The working directory to execute the command in."""
186    CHUNK_SIZE = 20
187    chunks = util_list.chunk(git_extra_args_to_chunk, CHUNK_SIZE)
188    for chunk in chunks:
189        execute_command(command, git_args + chunk, working_dir)
190
191
192def fetch(working_dir: str) -> None:
193    """Fetch latest commits from a Git repository using the specified working directory.
194
195    Args:
196    working_dir (str): The working directory of the repository."""
197    execute_command("fetch", [], working_dir)
198
199
200def fetch_notes(working_dir: str) -> None:
201    """Fetch notes from a Git repository using the specified working directory.
202    Args:
203    working_dir (str): The working directory of the repository."""
204    execute_command(
205        "fetch", ["origin", "refs/notes/*:refs/notes/*", "--force"], working_dir
206    )
207
208
209def checkout_branch(branch: str, path_to_repo_dir: str) -> str:
210    """Check out the specified branch in the Git repository at the given path.
211    Args:
212    branch (str): The branch to check out.
213    path_to_repo_dir (str): The path to the repository directory.
214    Returns:
215    str: The result of the checkout command."""
216    result = execute_command("checkout", [branch], path_to_repo_dir)
217    return result
218
219
220def checkout_at_start_of_date(
221    local_repo_location: str, branch: str, start_date: str
222) -> bool:
223    """Check out the branch at the start of the specified date in the local repository location.
224    Args:
225    local_repo_location (str): The local repository location.
226    branch (str): The branch to check out.
227    start_date (str): The start date for checking out the branch."""
228    # git rev-list -n 1 --first-parent --before="2023-12-22 00:00" main
229    last_commit = execute_command(
230        "rev-list",
231        ["-n", "1", "--first-parent", f'--before="{start_date} 00:00"', branch],
232        local_repo_location,
233    )
234    last_commit = last_commit.strip()
235    if last_commit:
236        checkout_branch(last_commit, local_repo_location)
237        return True
238    return False
239
240
241def checkout_head(local_repo_location: str, branch: str) -> None:
242    """Check out the HEAD of the specified branch in the local repository location.
243    Args:
244    local_repo_location (str): The local repository location.
245    branch (str): The branch to check out."""
246    checkout_branch(branch, local_repo_location)
247
248
249def check_has_no_changes(path_to_local_repo: str) -> None:
250    """Check if the local repository at the specified path has any changes.
251    Args:
252    path_to_local_repo (str): The path to the local repository directory."""
253    # should return empty, if no changes
254    result = execute_command("status", ["--porcelain"], path_to_local_repo)
255    if len(result) > 0:
256        message = f"Local repository at {path_to_local_repo} has changes. Please ensure the local repository is clean and has no outstanding changes."
257        raise RuntimeError(message)
258
259
260def _prepare_local_clone(
261    path_to_repo_dir: str, target_dir: str, is_mirror: bool
262) -> str:
263    """Prepare a local clone of the repository with the specified parameters.
264    Args:
265    path_to_repo_dir (str): The path to the repository directory.
266    target_dir (str): The temporary directory for fixing Git issues.
267    is_mirror (bool): A flag indicating whether the clone is a mirror.
268    Returns:
269    str: The path to the prepared local clone directory."""
270    local_clone_dir = os.path.join(
271        target_dir, "lm"
272    )  # lm = local_mirror (keeping path short)
273    util_dir.ensure_dir_exists(local_clone_dir)
274    args = [path_to_repo_dir]
275    if is_mirror:
276        args = ["--mirror"] + args
277    execute_command("clone", args, local_clone_dir)
278
279    local_repo_name = util_file.get_last_part_of_path(path_to_repo_dir)
280    if is_mirror:
281        local_repo_name += ".git"
282    else:
283        if local_repo_name.endswith(".git"):
284            local_repo_name = local_repo_name[:-4]
285    return os.path.join(local_clone_dir, local_repo_name)
286
287
288def prepare_local_full_clone(path_to_repo_dir: str, target_dir: str) -> str:
289    """Prepare a full local clone of the repository using the specified paths.
290    Args:
291    path_to_repo_dir (str): The path to the repository directory.
292    target_dir (str): The temporary directory for fixing Git issues.
293    Returns:
294    str: The path to the prepared full local clone directory."""
295    return _prepare_local_clone(path_to_repo_dir, target_dir, False)
296
297
298def prepare_local_mirror_clone(path_to_repo_dir: str, target_dir: str) -> str:
299    """Prepare a mirror local clone of the repository using the specified paths.
300    Args:
301    path_to_repo_dir (str): The path to the repository directory.
302    target_dir (str): The temporary directory for fixing Git issues.
303    Returns:
304    str: The path to the prepared mirror local clone directory."""
305    return _prepare_local_clone(path_to_repo_dir, target_dir, True)
306
307
308def gc_expire_reflog(repo_dir: str) -> None:
309    """Expire the reflog in the repository to aid in garbage collection.
310    Args:
311    repo_dir (str): The repository directory path."""
312    # Expires the reflog, which helps a following 'gc prune' to remove more garbage
313    execute_command("reflog", ["expire", "--expire=now", "--all"], repo_dir)
314
315
316def gc_prune_aggressive(mirror_clone_dir: str) -> str:
317    """Aggressively prune the repository to remove garbage using the mirror clone directory.
318    Args:
319    mirror_clone_dir (str): The mirror clone directory path.
320    Returns:
321    str: The result of the pruning operation."""
322    result = execute_command("gc", ["--prune=now", "--aggressive"], mirror_clone_dir)
323    return result
324
325
326def gc_prune_now(repo_dir: str) -> None:
327    """Prune the repository immediately to remove unnecessary objects.
328    Args:
329    repo_dir (str): The repository directory path."""
330    execute_command("gc", ["--prune=now"], repo_dir)
331
332
333def gc_prune_safe(repo_dir: str) -> None:
334    """Safely prune the repository to optimize object storage.
335    Args:
336    repo_dir (str): The repository directory path."""
337    execute_command("gc", [], repo_dir)
338
339
340def remove_origin_and_remote_branches(repo_dir: str) -> None:
341    """Remove the origin and remote branches from the repository.
342    Args:
343    repo_dir (str): The repository directory path."""
344    execute_command("remote", ["remove", "origin"], repo_dir)
345
346
347def remove_tag(repo_dir: str, tag: str) -> None:
348    """Remove the specified tag from the repository.
349    Args:
350    repo_dir (str): The repository directory path.
351    tag (str): The tag to be removed."""
352    execute_command("tag", ["-d", tag], repo_dir)
353
354
355def get_commits_after_date(
356    repo_dir: str, branches: list[str], start_date: str
357) -> list[str]:
358    """Get all commits after the specified date in the given branches of the repository.
359    Args:
360    repo_dir (str): The repository directory path.
361    branches (list): List of branches to check commits from.
362    start_date (str): The start date for filtering commits.
363    Returns:
364    list: A list of commits after the specified date."""
365    commits = []
366    for branch in branches:
367        checkout_branch(branch, repo_dir)
368
369        # git log --pretty=format:"%H" --after="2023-01-31"
370        day_before_cutoff = util_date.add_day_to_date(start_date, -1)
371        result = execute_command(
372            "log", ['--pretty=format:"%H"', f'--after="{day_before_cutoff}"'], repo_dir
373        )
374        commits += result.split("\n")
375    cleaned_commits = []
376    for commit in commits:
377        cleaned = commit.replace('"', "").strip()
378        cleaned_commits.append(cleaned)
379    return cleaned_commits
380
381
382def get_git_text_editor_or_none(
383    local_repo_location: str,
384) -> tuple[str | None, list[str] | None]:
385    """Get the default Git text editor or return None if not found.
386    Args:
387    local_repo_location (str): The local repository location.
388    Returns:
389    tuple: A tuple containing the path to the text editor program and its arguments, or (None, None) if not found."""
390    path_to_text_editor = get_config("core.editor", local_repo_location)
391    if path_to_text_editor is None or len(path_to_text_editor) == 0:
392        return (None, None)
393    error_message = f"Cannot parse default git text editor ('core.editor' = '{path_to_text_editor}')"
394    # Unfortunately, value can be like '"program" command'. To be able to execute 'program', we need to split that out:
395    program = path_to_text_editor
396    args = []
397    try:
398        if '"' in path_to_text_editor:
399            if path_to_text_editor.count('"') == 2:
400                parts = path_to_text_editor.split(
401                    '"'
402                )  # '"path to exe" cmd' -> ['', 'path to exe', ' cmd']
403                parts = util_list.strip_strings(parts)
404                program = parts[1]
405                args = parts[2:]
406    except Exception as e:
407        util_log.log_exception(e)
408        # Intentionally NOT passing exception onwards
409    if os.path.exists(program):
410        return (program, args)
411    util_print.print_custom(error_message)
412    return (None, None)
413
414
415def get_all_tags(path_to_local_repo: str) -> list[str]:
416    """Get all tags from the specified local repository.
417    Args:
418    path_to_local_repo (str): The path to the local repository directory.
419    Returns:
420    list: A list of all tags in the repository."""
421    tags = []
422    result = execute_command("tag", [], path_to_local_repo)
423    raw_tags = result.split("\n")
424    for raw_tag in raw_tags:
425        raw_tag = raw_tag.strip()
426        if raw_tag:
427            tags.append(raw_tag)
428    return tags
429
430
431def delete_all_tags(path_to_local_repo: str) -> None:
432    """Delete all tags from the specified local repository.
433    Args:
434    path_to_local_repo (str): The path to the local repository directory."""
435    tags = get_all_tags(path_to_local_repo)
436    execute_command_in_chunks(
437        "push", ["--delete", "origin", "--quiet"], tags, path_to_local_repo
438    )
439
440
441def get_all_origin_branches(path_to_local_repo: str) -> list[str]:
442    """Get all origin branches from the specified local repository.
443    Args:
444    path_to_local_repo (str): The path to the local repository directory.
445    Returns:
446    list: A list of all origin branches in the repository."""
447    branches = []
448    result = execute_command("branch", ["-r"], path_to_local_repo)
449    raw_branches = result.split("\n")
450    for raw_branch in raw_branches:
451        if "origin" not in raw_branch:
452            continue
453        if "->" in raw_branch:
454            continue
455        raw_branch = raw_branch.strip()
456        raw_branch = raw_branch.removeprefix("origin/")
457        if raw_branch:
458            branches.append(raw_branch)
459    return branches
460
461
462def delete_branches_except(
463    branches_to_keep: list[str], path_to_local_repo: str
464) -> None:
465    """Delete branches in the local repository except for the specified ones to keep.
466    Args:
467    branches_to_keep (list): List of branches to retain.
468    path_to_local_repo (str): The path to the local repository directory."""
469    # original bash:
470    #   git branch -r | grep -Eo 'origin/.*' | grep -v 'origin/main$' | sed 's/origin\///' | xargs git push origin -v --delete
471    # OR this one seems more reliable:
472    #   git branch -r | grep -Eo 'origin/.*' | grep -v 'origin/main$' | sed 's/origin\///' | xargs git push -d origin
473    branches = get_all_origin_branches(path_to_local_repo)
474    branches_to_delete = util_list.excluding(branches, branches_to_keep)
475
476    execute_command_in_chunks(
477        "push", ["-d", "origin"], branches_to_delete, path_to_local_repo
478    )
479
480
481def get_config(key: str, path_to_local_repo: str) -> str:
482    """Get the Git configuration setting value for the specified key in the local repository.
483    Args:
484    key (str): The configuration key to retrieve.
485    path_to_local_repo (str): The path to the local repository directory.
486    Returns:
487    str: The value of the configuration setting for the key."""
488    result = ""
489    try:
490        result = execute_command("config", ["--get", key], path_to_local_repo)
491    except RuntimeError as re:
492        # If the config key is missing, then git returns exit code 1 and empty stderr
493        if len(re.args) == 2 and re.args[1] == 1:
494            if config.IS_VERBOSE:
495                util_print.print_custom(
496                    f"[ok] git config has no value for setting '{key}'"
497                )
498            return ""
499        raise
500    return result
501
502
503def log_git_config(path_to_local_repo: str) -> None:
504    """Log interesting Git configuration settings that can impact text file handling.
505    Args:
506    path_to_local_repo (str): The path to the local repository directory."""
507    interesting_settings = [
508        "i18n.filesencoding",
509        "core.ignorecase",  # Probably best set to false.
510        "core.autocrlf",  # Caused encoding exception on validation (which we handle now). Probably best set to false - line-endings.
511        "diff.astextplain.textconv",
512    ]
513
514    logger.info("git config - text related settings")
515    for setting in interesting_settings:
516        value = get_config(setting, path_to_local_repo)
517        if len(value) > 0:
518            logger.info(f"  {setting}={value}")
519        else:
520            logger.info(f"  {setting} is not set [ok]")
521
522
523settings_best_set_to_false = [
524    "core.ignorecase",  # Probably best set to false.
525    "core.autocrlf",  # Caused encoding exception on validation (which we handle now). Probably best set to false - line-endings.
526]
527
528
529def is_git_ignored(directory: str, filename: str) -> bool:
530    """Check if a file is ignored by Git in the specified directory.
531    Args:
532    directory (str): The directory containing the file.
533    filename (str): The name of the file to check.
534    Returns:
535    bool: True if the file is ignored, False otherwise."""
536    try:
537        execute_command("check-ignore", [filename], directory)
538    except RuntimeError as re:
539        if len(re.args) == 2:
540            exit_code = re.args[1]
541            if exit_code == 0:
542                # 0 - One or more of the provided paths is ignored.
543                return True
544            elif exit_code == 1:
545                # 1 - None of the provided paths are ignored.
546                return False
547            # 128 - A fatal error was encountered.
548            # any other exit code
549            raise
550    # 0 - One or more of the provided paths is ignored.
551    return True
COMMIT_PARTS_SEPARTOR = '_||_'
logger = <Logger cornsnake.util_git (DEBUG)>
def get_branches_with_commits_in_origin_not_local(repo_dir: str, branches: list[str]) -> list[str]:
41def get_branches_with_commits_in_origin_not_local(
42    repo_dir: str, branches: list[str]
43) -> list[str]:
44    """
45    For all the given branches, find any missing commits: commits that are on origin but not this copy of the repository.
46    """
47    if not has_git_remote_urls(repo_dir):
48        util_print.print_warning(
49            f"Cannot check for missing commits: the local repo {repo_dir} has no remote origin"
50        )
51        return []
52    branches_with_missing_commits = []
53    fetch(repo_dir)
54    for branch in branches:
55        checkout_branch(branch, repo_dir)
56        result = execute_command("rev-list", [f"HEAD..origin/{branch}"], repo_dir)
57        if result.strip():
58            branches_with_missing_commits.append(branch)
59    return branches_with_missing_commits

For all the given branches, find any missing commits: commits that are on origin but not this copy of the repository.

def get_current_branch(local_repo_location: str) -> str:
62def get_current_branch(local_repo_location: str) -> str:
63    """
64    Function to get the current branch of the Git repository.
65
66    Args:
67    local_repo_location (str): The local repository location.
68
69    Returns:
70    str: The name of the current branch.
71    """
72    return execute_command(
73        "rev-parse", ["--abbrev-ref", "HEAD"], local_repo_location
74    ).strip()

Function to get the current branch of the Git repository.

Args: local_repo_location (str): The local repository location.

Returns: str: The name of the current branch.

def get_last_commit_id(path_to_repo_dir: str, file_only: str | None = None) -> str:
 77def get_last_commit_id(path_to_repo_dir: str, file_only: str | None = None) -> str:
 78    """Get the last commit ID based on the given path to the repository directory and an optional file parameter.
 79    Args:
 80    path_to_repo_dir (str): The path to the repository directory.
 81    file_only (str): Optional parameter to specify a specific file.
 82    Returns:
 83    str: The last commit ID."""
 84    # git log -n 1 --pretty=format:"%H_|\_%ad_|\_%s" --date=short BUILD.plz
 85    git_args = [
 86        "log",
 87        "-n",
 88        "1",
 89        f'--pretty=format:"%H{COMMIT_PARTS_SEPARTOR}%ad{COMMIT_PARTS_SEPARTOR}%s"',
 90        "--date=short",
 91    ]
 92    if file_only is not None:
 93        git_args += [file_only]
 94    result = util_proc.run_process_and_get_output(
 95        config.PATH_TO_GIT, git_args, path_to_repo_dir
 96    )
 97    if config.IS_VERBOSE:
 98        print("  " + result)
 99    parsed = _parse_result(result)
100    commit = parsed[0]
101    return commit

Get the last commit ID based on the given path to the repository directory and an optional file parameter. Args: path_to_repo_dir (str): The path to the repository directory. file_only (str): Optional parameter to specify a specific file. Returns: str: The last commit ID.

def has_git_remote_urls(repo_dir: str) -> bool:
104def has_git_remote_urls(repo_dir: str) -> bool:
105    """
106    Does the given git repository directory have any remote origin URLs.
107    """
108    result = execute_command("remote", ["-v"], repo_dir)
109    return len(result.strip()) > 0

Does the given git repository directory have any remote origin URLs.

def is_git_repo(repo_dir: str) -> bool:
112def is_git_repo(repo_dir: str) -> bool:
113    """
114    Is the given directory a git repository (contains .git folder)
115    """
116    path_to_git_config = os.path.join(repo_dir, ".git", "config")
117    return os.path.exists(path_to_git_config)

Is the given directory a git repository (contains .git folder)

def list_git_remote_urls(repo_dir: str) -> list[str]:
120def list_git_remote_urls(repo_dir: str) -> list[str]:
121    """
122    List any remote origin URLs of the given git repository directory.
123    """
124    if not has_git_remote_urls(repo_dir):
125        return []
126
127    remote_urls = []
128    # git remote -v
129    result = execute_command("remote", ["-v"], repo_dir)
130    lines = result.split("\n")
131
132    for line in lines:
133        if not line.strip():
134            continue
135        # example:
136        # origin  https://git.api.mendix.com/b7a238f5-07a8-4008-83a7-b98590f40969.git/ (fetch)
137        parts = line.split()
138        if len(parts) == 3:
139            remote_urls.append(parts[1])
140        else:
141            raise RuntimeError(f"Cannot parse output of git remote: {line}")
142
143    return remote_urls

List any remote origin URLs of the given git repository directory.

def get_git_remote_push_url(repo_dir: str) -> str:
146def get_git_remote_push_url(repo_dir: str) -> str:
147    """
148    Get the remote origin *push* URL of the given git repository directory.
149    """
150    try:
151        # git remote get-url --push origin
152        return execute_command(
153            "remote", ["get-url", "--push", "origin"], repo_dir
154        ).rstrip()
155    except Exception as e:
156        util_log.log_exception(e)
157        return ""

Get the remote origin push URL of the given git repository directory.

def execute_command(command: str, git_args: list[str], working_dir: str) -> str:
160def execute_command(command: str, git_args: list[str], working_dir: str) -> str:
161    """Execute a Git command with specified arguments in the given working directory.
162    Args:
163    command (str): The Git command to execute.
164    git_args (list): List of arguments for the Git command.
165    working_dir (str): The working directory to execute the command in.
166    Returns:
167    str: The output of the command."""
168    git_args_with_command = [command] + git_args
169    return util_proc.run_process_and_get_output(
170        config.PATH_TO_GIT, git_args_with_command, working_dir
171    )

Execute a Git command with specified arguments in the given working directory. Args: command (str): The Git command to execute. git_args (list): List of arguments for the Git command. working_dir (str): The working directory to execute the command in. Returns: str: The output of the command.

def execute_command_in_chunks( command: str, git_args: list[str], git_extra_args_to_chunk: list[str], working_dir: str) -> None:
175def execute_command_in_chunks(
176    command: str,
177    git_args: list[str],
178    git_extra_args_to_chunk: list[str],
179    working_dir: str,
180) -> None:
181    """Execute a Git command with arguments provided in chunks to avoid exceeding the parameter limit.
182    Args:
183    command (str): The Git command to execute.
184    git_args (list): List of arguments for the Git command.
185    git_extra_args_to_chunk (list): List of additional arguments to chunk and execute.
186    working_dir (str): The working directory to execute the command in."""
187    CHUNK_SIZE = 20
188    chunks = util_list.chunk(git_extra_args_to_chunk, CHUNK_SIZE)
189    for chunk in chunks:
190        execute_command(command, git_args + chunk, working_dir)

Execute a Git command with arguments provided in chunks to avoid exceeding the parameter limit. Args: command (str): The Git command to execute. git_args (list): List of arguments for the Git command. git_extra_args_to_chunk (list): List of additional arguments to chunk and execute. working_dir (str): The working directory to execute the command in.

def fetch(working_dir: str) -> None:
193def fetch(working_dir: str) -> None:
194    """Fetch latest commits from a Git repository using the specified working directory.
195
196    Args:
197    working_dir (str): The working directory of the repository."""
198    execute_command("fetch", [], working_dir)

Fetch latest commits from a Git repository using the specified working directory.

Args: working_dir (str): The working directory of the repository.

def fetch_notes(working_dir: str) -> None:
201def fetch_notes(working_dir: str) -> None:
202    """Fetch notes from a Git repository using the specified working directory.
203    Args:
204    working_dir (str): The working directory of the repository."""
205    execute_command(
206        "fetch", ["origin", "refs/notes/*:refs/notes/*", "--force"], working_dir
207    )

Fetch notes from a Git repository using the specified working directory. Args: working_dir (str): The working directory of the repository.

def checkout_branch(branch: str, path_to_repo_dir: str) -> str:
210def checkout_branch(branch: str, path_to_repo_dir: str) -> str:
211    """Check out the specified branch in the Git repository at the given path.
212    Args:
213    branch (str): The branch to check out.
214    path_to_repo_dir (str): The path to the repository directory.
215    Returns:
216    str: The result of the checkout command."""
217    result = execute_command("checkout", [branch], path_to_repo_dir)
218    return result

Check out the specified branch in the Git repository at the given path. Args: branch (str): The branch to check out. path_to_repo_dir (str): The path to the repository directory. Returns: str: The result of the checkout command.

def checkout_at_start_of_date(local_repo_location: str, branch: str, start_date: str) -> bool:
221def checkout_at_start_of_date(
222    local_repo_location: str, branch: str, start_date: str
223) -> bool:
224    """Check out the branch at the start of the specified date in the local repository location.
225    Args:
226    local_repo_location (str): The local repository location.
227    branch (str): The branch to check out.
228    start_date (str): The start date for checking out the branch."""
229    # git rev-list -n 1 --first-parent --before="2023-12-22 00:00" main
230    last_commit = execute_command(
231        "rev-list",
232        ["-n", "1", "--first-parent", f'--before="{start_date} 00:00"', branch],
233        local_repo_location,
234    )
235    last_commit = last_commit.strip()
236    if last_commit:
237        checkout_branch(last_commit, local_repo_location)
238        return True
239    return False

Check out the branch at the start of the specified date in the local repository location. Args: local_repo_location (str): The local repository location. branch (str): The branch to check out. start_date (str): The start date for checking out the branch.

def checkout_head(local_repo_location: str, branch: str) -> None:
242def checkout_head(local_repo_location: str, branch: str) -> None:
243    """Check out the HEAD of the specified branch in the local repository location.
244    Args:
245    local_repo_location (str): The local repository location.
246    branch (str): The branch to check out."""
247    checkout_branch(branch, local_repo_location)

Check out the HEAD of the specified branch in the local repository location. Args: local_repo_location (str): The local repository location. branch (str): The branch to check out.

def check_has_no_changes(path_to_local_repo: str) -> None:
250def check_has_no_changes(path_to_local_repo: str) -> None:
251    """Check if the local repository at the specified path has any changes.
252    Args:
253    path_to_local_repo (str): The path to the local repository directory."""
254    # should return empty, if no changes
255    result = execute_command("status", ["--porcelain"], path_to_local_repo)
256    if len(result) > 0:
257        message = f"Local repository at {path_to_local_repo} has changes. Please ensure the local repository is clean and has no outstanding changes."
258        raise RuntimeError(message)

Check if the local repository at the specified path has any changes. Args: path_to_local_repo (str): The path to the local repository directory.

def prepare_local_full_clone(path_to_repo_dir: str, target_dir: str) -> str:
289def prepare_local_full_clone(path_to_repo_dir: str, target_dir: str) -> str:
290    """Prepare a full local clone of the repository using the specified paths.
291    Args:
292    path_to_repo_dir (str): The path to the repository directory.
293    target_dir (str): The temporary directory for fixing Git issues.
294    Returns:
295    str: The path to the prepared full local clone directory."""
296    return _prepare_local_clone(path_to_repo_dir, target_dir, False)

Prepare a full local clone of the repository using the specified paths. Args: path_to_repo_dir (str): The path to the repository directory. target_dir (str): The temporary directory for fixing Git issues. Returns: str: The path to the prepared full local clone directory.

def prepare_local_mirror_clone(path_to_repo_dir: str, target_dir: str) -> str:
299def prepare_local_mirror_clone(path_to_repo_dir: str, target_dir: str) -> str:
300    """Prepare a mirror local clone of the repository using the specified paths.
301    Args:
302    path_to_repo_dir (str): The path to the repository directory.
303    target_dir (str): The temporary directory for fixing Git issues.
304    Returns:
305    str: The path to the prepared mirror local clone directory."""
306    return _prepare_local_clone(path_to_repo_dir, target_dir, True)

Prepare a mirror local clone of the repository using the specified paths. Args: path_to_repo_dir (str): The path to the repository directory. target_dir (str): The temporary directory for fixing Git issues. Returns: str: The path to the prepared mirror local clone directory.

def gc_expire_reflog(repo_dir: str) -> None:
309def gc_expire_reflog(repo_dir: str) -> None:
310    """Expire the reflog in the repository to aid in garbage collection.
311    Args:
312    repo_dir (str): The repository directory path."""
313    # Expires the reflog, which helps a following 'gc prune' to remove more garbage
314    execute_command("reflog", ["expire", "--expire=now", "--all"], repo_dir)

Expire the reflog in the repository to aid in garbage collection. Args: repo_dir (str): The repository directory path.

def gc_prune_aggressive(mirror_clone_dir: str) -> str:
317def gc_prune_aggressive(mirror_clone_dir: str) -> str:
318    """Aggressively prune the repository to remove garbage using the mirror clone directory.
319    Args:
320    mirror_clone_dir (str): The mirror clone directory path.
321    Returns:
322    str: The result of the pruning operation."""
323    result = execute_command("gc", ["--prune=now", "--aggressive"], mirror_clone_dir)
324    return result

Aggressively prune the repository to remove garbage using the mirror clone directory. Args: mirror_clone_dir (str): The mirror clone directory path. Returns: str: The result of the pruning operation.

def gc_prune_now(repo_dir: str) -> None:
327def gc_prune_now(repo_dir: str) -> None:
328    """Prune the repository immediately to remove unnecessary objects.
329    Args:
330    repo_dir (str): The repository directory path."""
331    execute_command("gc", ["--prune=now"], repo_dir)

Prune the repository immediately to remove unnecessary objects. Args: repo_dir (str): The repository directory path.

def gc_prune_safe(repo_dir: str) -> None:
334def gc_prune_safe(repo_dir: str) -> None:
335    """Safely prune the repository to optimize object storage.
336    Args:
337    repo_dir (str): The repository directory path."""
338    execute_command("gc", [], repo_dir)

Safely prune the repository to optimize object storage. Args: repo_dir (str): The repository directory path.

def remove_origin_and_remote_branches(repo_dir: str) -> None:
341def remove_origin_and_remote_branches(repo_dir: str) -> None:
342    """Remove the origin and remote branches from the repository.
343    Args:
344    repo_dir (str): The repository directory path."""
345    execute_command("remote", ["remove", "origin"], repo_dir)

Remove the origin and remote branches from the repository. Args: repo_dir (str): The repository directory path.

def remove_tag(repo_dir: str, tag: str) -> None:
348def remove_tag(repo_dir: str, tag: str) -> None:
349    """Remove the specified tag from the repository.
350    Args:
351    repo_dir (str): The repository directory path.
352    tag (str): The tag to be removed."""
353    execute_command("tag", ["-d", tag], repo_dir)

Remove the specified tag from the repository. Args: repo_dir (str): The repository directory path. tag (str): The tag to be removed.

def get_commits_after_date(repo_dir: str, branches: list[str], start_date: str) -> list[str]:
356def get_commits_after_date(
357    repo_dir: str, branches: list[str], start_date: str
358) -> list[str]:
359    """Get all commits after the specified date in the given branches of the repository.
360    Args:
361    repo_dir (str): The repository directory path.
362    branches (list): List of branches to check commits from.
363    start_date (str): The start date for filtering commits.
364    Returns:
365    list: A list of commits after the specified date."""
366    commits = []
367    for branch in branches:
368        checkout_branch(branch, repo_dir)
369
370        # git log --pretty=format:"%H" --after="2023-01-31"
371        day_before_cutoff = util_date.add_day_to_date(start_date, -1)
372        result = execute_command(
373            "log", ['--pretty=format:"%H"', f'--after="{day_before_cutoff}"'], repo_dir
374        )
375        commits += result.split("\n")
376    cleaned_commits = []
377    for commit in commits:
378        cleaned = commit.replace('"', "").strip()
379        cleaned_commits.append(cleaned)
380    return cleaned_commits

Get all commits after the specified date in the given branches of the repository. Args: repo_dir (str): The repository directory path. branches (list): List of branches to check commits from. start_date (str): The start date for filtering commits. Returns: list: A list of commits after the specified date.

def get_git_text_editor_or_none(local_repo_location: str) -> tuple[str | None, list[str] | None]:
383def get_git_text_editor_or_none(
384    local_repo_location: str,
385) -> tuple[str | None, list[str] | None]:
386    """Get the default Git text editor or return None if not found.
387    Args:
388    local_repo_location (str): The local repository location.
389    Returns:
390    tuple: A tuple containing the path to the text editor program and its arguments, or (None, None) if not found."""
391    path_to_text_editor = get_config("core.editor", local_repo_location)
392    if path_to_text_editor is None or len(path_to_text_editor) == 0:
393        return (None, None)
394    error_message = f"Cannot parse default git text editor ('core.editor' = '{path_to_text_editor}')"
395    # Unfortunately, value can be like '"program" command'. To be able to execute 'program', we need to split that out:
396    program = path_to_text_editor
397    args = []
398    try:
399        if '"' in path_to_text_editor:
400            if path_to_text_editor.count('"') == 2:
401                parts = path_to_text_editor.split(
402                    '"'
403                )  # '"path to exe" cmd' -> ['', 'path to exe', ' cmd']
404                parts = util_list.strip_strings(parts)
405                program = parts[1]
406                args = parts[2:]
407    except Exception as e:
408        util_log.log_exception(e)
409        # Intentionally NOT passing exception onwards
410    if os.path.exists(program):
411        return (program, args)
412    util_print.print_custom(error_message)
413    return (None, None)

Get the default Git text editor or return None if not found. Args: local_repo_location (str): The local repository location. Returns: tuple: A tuple containing the path to the text editor program and its arguments, or (None, None) if not found.

def get_all_tags(path_to_local_repo: str) -> list[str]:
416def get_all_tags(path_to_local_repo: str) -> list[str]:
417    """Get all tags from the specified local repository.
418    Args:
419    path_to_local_repo (str): The path to the local repository directory.
420    Returns:
421    list: A list of all tags in the repository."""
422    tags = []
423    result = execute_command("tag", [], path_to_local_repo)
424    raw_tags = result.split("\n")
425    for raw_tag in raw_tags:
426        raw_tag = raw_tag.strip()
427        if raw_tag:
428            tags.append(raw_tag)
429    return tags

Get all tags from the specified local repository. Args: path_to_local_repo (str): The path to the local repository directory. Returns: list: A list of all tags in the repository.

def delete_all_tags(path_to_local_repo: str) -> None:
432def delete_all_tags(path_to_local_repo: str) -> None:
433    """Delete all tags from the specified local repository.
434    Args:
435    path_to_local_repo (str): The path to the local repository directory."""
436    tags = get_all_tags(path_to_local_repo)
437    execute_command_in_chunks(
438        "push", ["--delete", "origin", "--quiet"], tags, path_to_local_repo
439    )

Delete all tags from the specified local repository. Args: path_to_local_repo (str): The path to the local repository directory.

def get_all_origin_branches(path_to_local_repo: str) -> list[str]:
442def get_all_origin_branches(path_to_local_repo: str) -> list[str]:
443    """Get all origin branches from the specified local repository.
444    Args:
445    path_to_local_repo (str): The path to the local repository directory.
446    Returns:
447    list: A list of all origin branches in the repository."""
448    branches = []
449    result = execute_command("branch", ["-r"], path_to_local_repo)
450    raw_branches = result.split("\n")
451    for raw_branch in raw_branches:
452        if "origin" not in raw_branch:
453            continue
454        if "->" in raw_branch:
455            continue
456        raw_branch = raw_branch.strip()
457        raw_branch = raw_branch.removeprefix("origin/")
458        if raw_branch:
459            branches.append(raw_branch)
460    return branches

Get all origin branches from the specified local repository. Args: path_to_local_repo (str): The path to the local repository directory. Returns: list: A list of all origin branches in the repository.

def delete_branches_except(branches_to_keep: list[str], path_to_local_repo: str) -> None:
463def delete_branches_except(
464    branches_to_keep: list[str], path_to_local_repo: str
465) -> None:
466    """Delete branches in the local repository except for the specified ones to keep.
467    Args:
468    branches_to_keep (list): List of branches to retain.
469    path_to_local_repo (str): The path to the local repository directory."""
470    # original bash:
471    #   git branch -r | grep -Eo 'origin/.*' | grep -v 'origin/main$' | sed 's/origin\///' | xargs git push origin -v --delete
472    # OR this one seems more reliable:
473    #   git branch -r | grep -Eo 'origin/.*' | grep -v 'origin/main$' | sed 's/origin\///' | xargs git push -d origin
474    branches = get_all_origin_branches(path_to_local_repo)
475    branches_to_delete = util_list.excluding(branches, branches_to_keep)
476
477    execute_command_in_chunks(
478        "push", ["-d", "origin"], branches_to_delete, path_to_local_repo
479    )

Delete branches in the local repository except for the specified ones to keep. Args: branches_to_keep (list): List of branches to retain. path_to_local_repo (str): The path to the local repository directory.

def get_config(key: str, path_to_local_repo: str) -> str:
482def get_config(key: str, path_to_local_repo: str) -> str:
483    """Get the Git configuration setting value for the specified key in the local repository.
484    Args:
485    key (str): The configuration key to retrieve.
486    path_to_local_repo (str): The path to the local repository directory.
487    Returns:
488    str: The value of the configuration setting for the key."""
489    result = ""
490    try:
491        result = execute_command("config", ["--get", key], path_to_local_repo)
492    except RuntimeError as re:
493        # If the config key is missing, then git returns exit code 1 and empty stderr
494        if len(re.args) == 2 and re.args[1] == 1:
495            if config.IS_VERBOSE:
496                util_print.print_custom(
497                    f"[ok] git config has no value for setting '{key}'"
498                )
499            return ""
500        raise
501    return result

Get the Git configuration setting value for the specified key in the local repository. Args: key (str): The configuration key to retrieve. path_to_local_repo (str): The path to the local repository directory. Returns: str: The value of the configuration setting for the key.

def log_git_config(path_to_local_repo: str) -> None:
504def log_git_config(path_to_local_repo: str) -> None:
505    """Log interesting Git configuration settings that can impact text file handling.
506    Args:
507    path_to_local_repo (str): The path to the local repository directory."""
508    interesting_settings = [
509        "i18n.filesencoding",
510        "core.ignorecase",  # Probably best set to false.
511        "core.autocrlf",  # Caused encoding exception on validation (which we handle now). Probably best set to false - line-endings.
512        "diff.astextplain.textconv",
513    ]
514
515    logger.info("git config - text related settings")
516    for setting in interesting_settings:
517        value = get_config(setting, path_to_local_repo)
518        if len(value) > 0:
519            logger.info(f"  {setting}={value}")
520        else:
521            logger.info(f"  {setting} is not set [ok]")

Log interesting Git configuration settings that can impact text file handling. Args: path_to_local_repo (str): The path to the local repository directory.

settings_best_set_to_false = ['core.ignorecase', 'core.autocrlf']
def is_git_ignored(directory: str, filename: str) -> bool:
530def is_git_ignored(directory: str, filename: str) -> bool:
531    """Check if a file is ignored by Git in the specified directory.
532    Args:
533    directory (str): The directory containing the file.
534    filename (str): The name of the file to check.
535    Returns:
536    bool: True if the file is ignored, False otherwise."""
537    try:
538        execute_command("check-ignore", [filename], directory)
539    except RuntimeError as re:
540        if len(re.args) == 2:
541            exit_code = re.args[1]
542            if exit_code == 0:
543                # 0 - One or more of the provided paths is ignored.
544                return True
545            elif exit_code == 1:
546                # 1 - None of the provided paths are ignored.
547                return False
548            # 128 - A fatal error was encountered.
549            # any other exit code
550            raise
551    # 0 - One or more of the provided paths is ignored.
552    return True

Check if a file is ignored by Git in the specified directory. Args: directory (str): The directory containing the file. filename (str): The name of the file to check. Returns: bool: True if the file is ignored, False otherwise.