diff options
| author | Gavin Mak <gavinmak@google.com> | 2026-02-06 14:18:00 -0800 |
|---|---|---|
| committer | LUCI <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2026-03-17 14:29:38 -0700 |
| commit | f14c577fce155bc4955e83b1d9444dc67f1511aa (patch) | |
| tree | 9ddceaa0b2e7d1062b65404249c8c6ac3534ff4f /project.py | |
| parent | 67881c0c3b27d850c57070919f4476b370de7768 (diff) | |
| download | git-repo-f14c577fce155bc4955e83b1d9444dc67f1511aa.tar.gz | |
project: avoid direct packed-refs writes during fetch
Replace raw file manipulation with native `git update-ref` commands
inside a try/finally block to ensure temp refs are created/cleaned up
regardless of storage format.
Bug: 476209856
Change-Id: I228e81d3d3b323328260f6672075193421c8dc47
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/550421
Tested-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Diffstat (limited to 'project.py')
| -rw-r--r-- | project.py | 311 |
1 files changed, 161 insertions, 150 deletions
diff --git a/project.py b/project.py index f3ae9ab1d..b3c906b18 100644 --- a/project.py +++ b/project.py | |||
| @@ -2516,18 +2516,20 @@ class Project: | |||
| 2516 | if not remote.PreConnectFetch(ssh_proxy): | 2516 | if not remote.PreConnectFetch(ssh_proxy): |
| 2517 | ssh_proxy = None | 2517 | ssh_proxy = None |
| 2518 | 2518 | ||
| 2519 | alt_tmp_refs = [] | ||
| 2519 | if initial: | 2520 | if initial: |
| 2520 | if alt_dir and "objects" == os.path.basename(alt_dir): | 2521 | if alt_dir and "objects" == os.path.basename(alt_dir): |
| 2521 | ref_dir = os.path.dirname(alt_dir) | 2522 | ref_dir = os.path.dirname(alt_dir) |
| 2522 | packed_refs = os.path.join(self.gitdir, "packed-refs") | ||
| 2523 | 2523 | ||
| 2524 | all_refs = self.bare_ref.all | 2524 | all_refs = self.bare_ref.all |
| 2525 | ids = set(all_refs.values()) | 2525 | ids = set(all_refs.values()) |
| 2526 | tmp = set() | 2526 | |
| 2527 | update_ref_cmds = [] | ||
| 2527 | 2528 | ||
| 2528 | for r, ref_id in GitRefs(ref_dir).all.items(): | 2529 | for r, ref_id in GitRefs(ref_dir).all.items(): |
| 2529 | if r not in all_refs: | 2530 | if r not in all_refs: |
| 2530 | if r.startswith(R_TAGS) or remote.WritesTo(r): | 2531 | if r.startswith(R_TAGS) or remote.WritesTo(r): |
| 2532 | update_ref_cmds.append(f"create {r} {ref_id}\n") | ||
| 2531 | all_refs[r] = ref_id | 2533 | all_refs[r] = ref_id |
| 2532 | ids.add(ref_id) | 2534 | ids.add(ref_id) |
| 2533 | continue | 2535 | continue |
| @@ -2536,22 +2538,18 @@ class Project: | |||
| 2536 | continue | 2538 | continue |
| 2537 | 2539 | ||
| 2538 | r = "refs/_alt/%s" % ref_id | 2540 | r = "refs/_alt/%s" % ref_id |
| 2541 | update_ref_cmds.append(f"create {r} {ref_id}\n") | ||
| 2539 | all_refs[r] = ref_id | 2542 | all_refs[r] = ref_id |
| 2540 | ids.add(ref_id) | 2543 | ids.add(ref_id) |
| 2541 | tmp.add(r) | 2544 | alt_tmp_refs.append(r) |
| 2542 | 2545 | ||
| 2543 | tmp_packed_lines = [] | 2546 | if update_ref_cmds: |
| 2544 | old_packed_lines = [] | 2547 | GitCommand( |
| 2545 | 2548 | self, | |
| 2546 | for r in sorted(all_refs): | 2549 | ["update-ref", "--no-deref", "--stdin"], |
| 2547 | line = f"{all_refs[r]} {r}\n" | 2550 | bare=True, |
| 2548 | tmp_packed_lines.append(line) | 2551 | input="".join(update_ref_cmds), |
| 2549 | if r not in tmp: | 2552 | ).Wait() |
| 2550 | old_packed_lines.append(line) | ||
| 2551 | |||
| 2552 | tmp_packed = "".join(tmp_packed_lines) | ||
| 2553 | old_packed = "".join(old_packed_lines) | ||
| 2554 | _lwrite(packed_refs, tmp_packed) | ||
| 2555 | else: | 2553 | else: |
| 2556 | alt_dir = None | 2554 | alt_dir = None |
| 2557 | 2555 | ||
| @@ -2652,147 +2650,160 @@ class Project: | |||
| 2652 | retry_fetches = max(retry_fetches, 2) | 2650 | retry_fetches = max(retry_fetches, 2) |
| 2653 | retry_cur_sleep = retry_sleep_initial_sec | 2651 | retry_cur_sleep = retry_sleep_initial_sec |
| 2654 | ok = prune_tried = False | 2652 | ok = prune_tried = False |
| 2655 | for try_n in range(retry_fetches): | 2653 | try: |
| 2656 | verify_command = try_n == retry_fetches - 1 | 2654 | for try_n in range(retry_fetches): |
| 2657 | gitcmd = GitCommand( | 2655 | verify_command = try_n == retry_fetches - 1 |
| 2658 | self, | 2656 | gitcmd = GitCommand( |
| 2659 | cmd, | ||
| 2660 | bare=True, | ||
| 2661 | objdir=os.path.join(self.objdir, "objects"), | ||
| 2662 | ssh_proxy=ssh_proxy, | ||
| 2663 | merge_output=True, | ||
| 2664 | capture_stdout=quiet or bool(output_redir), | ||
| 2665 | verify_command=verify_command, | ||
| 2666 | ) | ||
| 2667 | if gitcmd.stdout and not quiet and output_redir: | ||
| 2668 | output_redir.write(gitcmd.stdout) | ||
| 2669 | ret = gitcmd.Wait() | ||
| 2670 | if ret == 0: | ||
| 2671 | ok = True | ||
| 2672 | break | ||
| 2673 | |||
| 2674 | # Retry later due to HTTP 429 Too Many Requests. | ||
| 2675 | elif ( | ||
| 2676 | gitcmd.stdout | ||
| 2677 | and "error:" in gitcmd.stdout | ||
| 2678 | and "HTTP 429" in gitcmd.stdout | ||
| 2679 | ): | ||
| 2680 | # Fallthru to sleep+retry logic at the bottom. | ||
| 2681 | pass | ||
| 2682 | |||
| 2683 | # TODO(b/360889369#comment24): git may gc commits incorrectly. | ||
| 2684 | # Until the root cause is fixed, retry fetch with --refetch which | ||
| 2685 | # will bring the repository into a good state. | ||
| 2686 | elif gitcmd.stdout and ( | ||
| 2687 | "could not parse commit" in gitcmd.stdout | ||
| 2688 | or "unable to parse commit" in gitcmd.stdout | ||
| 2689 | ): | ||
| 2690 | cmd.insert(1, "--refetch") | ||
| 2691 | print( | ||
| 2692 | "could not parse commit error, retrying with refetch", | ||
| 2693 | file=output_redir, | ||
| 2694 | ) | ||
| 2695 | continue | ||
| 2696 | |||
| 2697 | # Try to prune remote branches once in case there are conflicts. | ||
| 2698 | # For example, if the remote had refs/heads/upstream, but deleted | ||
| 2699 | # that and now has refs/heads/upstream/foo. | ||
| 2700 | elif ( | ||
| 2701 | gitcmd.stdout | ||
| 2702 | and "error:" in gitcmd.stdout | ||
| 2703 | and "git remote prune" in gitcmd.stdout | ||
| 2704 | and not prune_tried | ||
| 2705 | ): | ||
| 2706 | prune_tried = True | ||
| 2707 | prunecmd = GitCommand( | ||
| 2708 | self, | 2657 | self, |
| 2709 | ["remote", "prune", name], | 2658 | cmd, |
| 2710 | bare=True, | 2659 | bare=True, |
| 2660 | objdir=os.path.join(self.objdir, "objects"), | ||
| 2711 | ssh_proxy=ssh_proxy, | 2661 | ssh_proxy=ssh_proxy, |
| 2662 | merge_output=True, | ||
| 2663 | capture_stdout=quiet or bool(output_redir), | ||
| 2664 | verify_command=verify_command, | ||
| 2712 | ) | 2665 | ) |
| 2713 | ret = prunecmd.Wait() | 2666 | if gitcmd.stdout and not quiet and output_redir: |
| 2714 | if ret: | 2667 | output_redir.write(gitcmd.stdout) |
| 2668 | ret = gitcmd.Wait() | ||
| 2669 | if ret == 0: | ||
| 2670 | ok = True | ||
| 2715 | break | 2671 | break |
| 2716 | print( | ||
| 2717 | "retrying fetch after pruning remote branches", | ||
| 2718 | file=output_redir, | ||
| 2719 | ) | ||
| 2720 | # Continue right away so we don't sleep as we shouldn't need to. | ||
| 2721 | continue | ||
| 2722 | elif ( | ||
| 2723 | ret == 128 | ||
| 2724 | and gitcmd.stdout | ||
| 2725 | and "fatal: could not read Username" in gitcmd.stdout | ||
| 2726 | ): | ||
| 2727 | # User needs to be authenticated, and Git wants to prompt for | ||
| 2728 | # username and password. | ||
| 2729 | print( | ||
| 2730 | "git requires authentication, but repo cannot perform " | ||
| 2731 | "interactive authentication. Check git credentials.", | ||
| 2732 | file=output_redir, | ||
| 2733 | ) | ||
| 2734 | break | ||
| 2735 | elif ( | ||
| 2736 | ret == 128 | ||
| 2737 | and gitcmd.stdout | ||
| 2738 | and "remote helper 'sso' aborted session" in gitcmd.stdout | ||
| 2739 | ): | ||
| 2740 | # User needs to be authenticated, and Git wants to prompt for | ||
| 2741 | # username and password. | ||
| 2742 | print( | ||
| 2743 | "git requires authentication, but repo cannot perform " | ||
| 2744 | "interactive authentication.", | ||
| 2745 | file=output_redir, | ||
| 2746 | ) | ||
| 2747 | raise GitAuthError(gitcmd.stdout) | ||
| 2748 | break | ||
| 2749 | elif current_branch_only and is_sha1 and ret == 128: | ||
| 2750 | # Exit code 128 means "couldn't find the ref you asked for"; if | ||
| 2751 | # we're in sha1 mode, we just tried sync'ing from the upstream | ||
| 2752 | # field; it doesn't exist, thus abort the optimization attempt | ||
| 2753 | # and do a full sync. | ||
| 2754 | break | ||
| 2755 | elif depth and is_sha1 and ret == 1: | ||
| 2756 | # In sha1 mode, when depth is enabled, syncing the revision | ||
| 2757 | # from upstream may not work because some servers only allow | ||
| 2758 | # fetching named refs. Fetching a specific sha1 may result | ||
| 2759 | # in an error like 'server does not allow request for | ||
| 2760 | # unadvertised object'. In this case, attempt a full sync | ||
| 2761 | # without depth. | ||
| 2762 | break | ||
| 2763 | elif ret < 0: | ||
| 2764 | # Git died with a signal, exit immediately. | ||
| 2765 | break | ||
| 2766 | 2672 | ||
| 2767 | # Figure out how long to sleep before the next attempt, if there is | 2673 | # Retry later due to HTTP 429 Too Many Requests. |
| 2768 | # one. | 2674 | elif ( |
| 2769 | if not verbose and gitcmd.stdout: | 2675 | gitcmd.stdout |
| 2770 | print( | 2676 | and "error:" in gitcmd.stdout |
| 2771 | f"\n{self.name}:\n{gitcmd.stdout}", | 2677 | and "HTTP 429" in gitcmd.stdout |
| 2772 | end="", | 2678 | ): |
| 2773 | file=output_redir, | 2679 | # Fallthru to sleep+retry logic at the bottom. |
| 2774 | ) | 2680 | pass |
| 2775 | if try_n < retry_fetches - 1: | ||
| 2776 | print( | ||
| 2777 | "%s: sleeping %s seconds before retrying" | ||
| 2778 | % (self.name, retry_cur_sleep), | ||
| 2779 | file=output_redir, | ||
| 2780 | ) | ||
| 2781 | time.sleep(retry_cur_sleep) | ||
| 2782 | retry_cur_sleep = min( | ||
| 2783 | retry_exp_factor * retry_cur_sleep, MAXIMUM_RETRY_SLEEP_SEC | ||
| 2784 | ) | ||
| 2785 | retry_cur_sleep *= 1 - random.uniform( | ||
| 2786 | -RETRY_JITTER_PERCENT, RETRY_JITTER_PERCENT | ||
| 2787 | ) | ||
| 2788 | 2681 | ||
| 2789 | if initial: | 2682 | # TODO(b/360889369#comment24): git may gc commits incorrectly. |
| 2790 | if alt_dir: | 2683 | # Until the root cause is fixed, retry fetch with --refetch |
| 2791 | if old_packed != "": | 2684 | # which will bring the repository into a good state. |
| 2792 | _lwrite(packed_refs, old_packed) | 2685 | elif gitcmd.stdout and ( |
| 2793 | else: | 2686 | "could not parse commit" in gitcmd.stdout |
| 2794 | platform_utils.remove(packed_refs) | 2687 | or "unable to parse commit" in gitcmd.stdout |
| 2795 | self.bare_git.pack_refs("--all", "--prune") | 2688 | ): |
| 2689 | cmd.insert(1, "--refetch") | ||
| 2690 | print( | ||
| 2691 | "could not parse commit error, retrying with refetch", | ||
| 2692 | file=output_redir, | ||
| 2693 | ) | ||
| 2694 | continue | ||
| 2695 | |||
| 2696 | # Try to prune remote branches once in case there are conflicts. | ||
| 2697 | # For example, if the remote had refs/heads/upstream, but | ||
| 2698 | # deleted that and now has refs/heads/upstream/foo. | ||
| 2699 | elif ( | ||
| 2700 | gitcmd.stdout | ||
| 2701 | and "error:" in gitcmd.stdout | ||
| 2702 | and "git remote prune" in gitcmd.stdout | ||
| 2703 | and not prune_tried | ||
| 2704 | ): | ||
| 2705 | prune_tried = True | ||
| 2706 | prunecmd = GitCommand( | ||
| 2707 | self, | ||
| 2708 | ["remote", "prune", name], | ||
| 2709 | bare=True, | ||
| 2710 | ssh_proxy=ssh_proxy, | ||
| 2711 | ) | ||
| 2712 | ret = prunecmd.Wait() | ||
| 2713 | if ret: | ||
| 2714 | break | ||
| 2715 | print( | ||
| 2716 | "retrying fetch after pruning remote branches", | ||
| 2717 | file=output_redir, | ||
| 2718 | ) | ||
| 2719 | # Continue right away so we don't sleep as we shouldn't | ||
| 2720 | # need to. | ||
| 2721 | continue | ||
| 2722 | elif ( | ||
| 2723 | ret == 128 | ||
| 2724 | and gitcmd.stdout | ||
| 2725 | and "fatal: could not read Username" in gitcmd.stdout | ||
| 2726 | ): | ||
| 2727 | # User needs to be authenticated, and Git wants to prompt | ||
| 2728 | # for username and password. | ||
| 2729 | print( | ||
| 2730 | "git requires authentication, but repo cannot perform " | ||
| 2731 | "interactive authentication. Check git credentials.", | ||
| 2732 | file=output_redir, | ||
| 2733 | ) | ||
| 2734 | break | ||
| 2735 | elif ( | ||
| 2736 | ret == 128 | ||
| 2737 | and gitcmd.stdout | ||
| 2738 | and "remote helper 'sso' aborted session" in gitcmd.stdout | ||
| 2739 | ): | ||
| 2740 | # User needs to be authenticated, and Git wants to prompt | ||
| 2741 | # for username and password. | ||
| 2742 | print( | ||
| 2743 | "git requires authentication, but repo cannot perform " | ||
| 2744 | "interactive authentication.", | ||
| 2745 | file=output_redir, | ||
| 2746 | ) | ||
| 2747 | raise GitAuthError(gitcmd.stdout) | ||
| 2748 | break | ||
| 2749 | elif current_branch_only and is_sha1 and ret == 128: | ||
| 2750 | # Exit code 128 means "couldn't find the ref you asked for"; | ||
| 2751 | # if we're in sha1 mode, we just tried sync'ing from the | ||
| 2752 | # upstream field; it doesn't exist, thus abort the | ||
| 2753 | # optimization attempt and do a full sync. | ||
| 2754 | break | ||
| 2755 | elif depth and is_sha1 and ret == 1: | ||
| 2756 | # In sha1 mode, when depth is enabled, syncing the revision | ||
| 2757 | # from upstream may not work because some servers only allow | ||
| 2758 | # fetching named refs. Fetching a specific sha1 may result | ||
| 2759 | # in an error like 'server does not allow request for | ||
| 2760 | # unadvertised object'. In this case, attempt a full sync | ||
| 2761 | # without depth. | ||
| 2762 | break | ||
| 2763 | elif ret < 0: | ||
| 2764 | # Git died with a signal, exit immediately. | ||
| 2765 | break | ||
| 2766 | |||
| 2767 | # Figure out how long to sleep before the next attempt, if | ||
| 2768 | # there is one. | ||
| 2769 | if not verbose and gitcmd.stdout: | ||
| 2770 | print( | ||
| 2771 | f"\n{self.name}:\n{gitcmd.stdout}", | ||
| 2772 | end="", | ||
| 2773 | file=output_redir, | ||
| 2774 | ) | ||
| 2775 | if try_n < retry_fetches - 1: | ||
| 2776 | print( | ||
| 2777 | "%s: sleeping %s seconds before retrying" | ||
| 2778 | % (self.name, retry_cur_sleep), | ||
| 2779 | file=output_redir, | ||
| 2780 | ) | ||
| 2781 | time.sleep(retry_cur_sleep) | ||
| 2782 | retry_cur_sleep = min( | ||
| 2783 | retry_exp_factor * retry_cur_sleep, | ||
| 2784 | MAXIMUM_RETRY_SLEEP_SEC, | ||
| 2785 | ) | ||
| 2786 | retry_cur_sleep *= 1 - random.uniform( | ||
| 2787 | -RETRY_JITTER_PERCENT, RETRY_JITTER_PERCENT | ||
| 2788 | ) | ||
| 2789 | finally: | ||
| 2790 | if initial: | ||
| 2791 | if alt_tmp_refs: | ||
| 2792 | delete_cmds = "".join( | ||
| 2793 | f"delete {ref}\n" for ref in alt_tmp_refs | ||
| 2794 | ) | ||
| 2795 | GitCommand( | ||
| 2796 | self, | ||
| 2797 | ["update-ref", "--no-deref", "--stdin"], | ||
| 2798 | bare=True, | ||
| 2799 | input=delete_cmds, | ||
| 2800 | log_as_error=False, | ||
| 2801 | ).Wait() | ||
| 2802 | |||
| 2803 | for ref in alt_tmp_refs: | ||
| 2804 | self.bare_ref.deleted(ref) | ||
| 2805 | |||
| 2806 | self.bare_git.pack_refs("--all", "--prune") | ||
| 2796 | 2807 | ||
| 2797 | if is_sha1 and current_branch_only: | 2808 | if is_sha1 and current_branch_only: |
| 2798 | # We just synced the upstream given branch; verify we | 2809 | # We just synced the upstream given branch; verify we |
