[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[tor-commits] [Git][tpo/applications/tor-browser][tor-browser-147.0a1-16.0-1] 17 commits: fixup! TB 40597: Implement TorSettings module



Title: GitLab

henry pushed to branch tor-browser-147.0a1-16.0-1 at The Tor Project / Applications / Tor Browser

Commits:

  • 629a5b42
    by Beatriz Rizental at 2026-01-12T17:08:09+00:00
    fixup! TB 40597: Implement TorSettings module
    
    Fix linter issue.
    
  • 55b87f13
    by june wilde at 2026-01-12T17:08:11+00:00
    fixup! BB 41459: WebRTC fails to build under mingw (Part 2)
    
  • 7b0257e4
    by june wilde at 2026-01-12T17:08:12+00:00
    fixup! BB 41459: WebRTC fails to build under mingw (Part 1)
    
  • a40941d3
    by june wilde at 2026-01-12T17:08:13+00:00
    fixup! BB 41459: WebRTC fails to build under mingw (Part 5)
    
  • c1a4a89e
    by Henry Wilkes at 2026-01-12T17:08:14+00:00
    fixup! BB 41803: Add some developer tools for working on tor-browser.
    
    TB 44367: Make git_get return the stdout string, rather than a list.
    
    Add git_lines to generate lines.
    
  • abf794c5
    by Henry Wilkes at 2026-01-12T17:08:15+00:00
    fixup! BB 41803: Add some developer tools for working on tor-browser.
    
    TB 44367: Use raw diff to get list of file changes.
    
  • 9494d12d
    by Henry Wilkes at 2026-01-12T17:08:16+00:00
    fixup! BB 41803: Add some developer tools for working on tor-browser.
    
    TB 44367: Add type annotations and parameter documentation.
    
  • 39511abd
    by Henry Wilkes at 2026-01-12T17:08:17+00:00
    fixup! BB 41803: Add some developer tools for working on tor-browser.
    
    TB 44367: Make the argcomplete module optional.
    
  • 7f98a5fd
    by Henry Wilkes at 2026-01-12T17:08:19+00:00
    fixup! BB 41803: Add some developer tools for working on tor-browser.
    
    TB 44367: Use function caching instead of global variables.
    
  • b000589a
    by Henry Wilkes at 2026-01-12T17:08:20+00:00
    fixup! BB 41803: Add some developer tools for working on tor-browser.
    
    TB 44367: Fetch FIREFOX_ tags from the remote if they are missing.
    
  • da6f0e38
    by Henry Wilkes at 2026-01-12T17:08:21+00:00
    fixup! BB 41803: Add some developer tools for working on tor-browser.
    
    TB 44367: Improve the auto-fixup/auto-commit command.
    
    The auto-fixup command was renamed to auto-commit. It now also handles:
    
    1. Already staged changes.
    2. Untracked/added files.
    3. Removed files.
    4. Renamed files.
    5. Allowing the user to create a new commit.
    
  • 82513ac3
    by Pier Angelo Vendrame at 2026-01-12T17:08:22+00:00
    fixup! Tweaks to the build system
    
    This reverts commit 4e4d1a17c3fd6148d6ecfdeb3831070e068470af.
    
    This reverts commit d0aa909310783cf4bdb219d34f5031d5123f8749.
    
  • 67f0febf
    by Beatriz Rizental at 2026-01-12T17:08:23+00:00
    fixup! TB 43817: Add tests for Tor Browser
    
    Bug 43243:
    Rename marionette.toml to manifest.toml just for consistency sake. All
    other marionette manifest files are named that.
    
  • 0ce16c8b
    by Beatriz Rizental at 2026-01-12T17:08:24+00:00
    fixup! TB 43817: Add tests for Tor Browser
    
    Bug 43243:
    Make it possible to run all tor test just by using tags. Just adding the
    tag wasn't enough though, had to add it to a list of tests in
    integration-tests.toml. Might be an upstream bug, but I don't feel like
    debugging that. Also it's easy enough.
    
  • e0135559
    by Beatriz Rizental at 2026-01-12T17:08:25+00:00
    fixup! TB 43817: Add tests for Tor Browser
    
    Bug 43243:
    iInclude testing/tor directory into common test archive.
    
  • b9e408af
    by Beatriz Rizental at 2026-01-12T17:08:26+00:00
    fixup! TB 43817: Add tests for Tor Browser
    
    Bug 43243:
    BUGFIX: Make it possible to run both tor browser tests in sequence.
    Turns out they need to explicitly close the browser, otherwise
    marionette doesn't do that for us unless it's the end of the whole
    suite. We want a restart, because we want to bootstrap before each test.
    
  • ef6aca73
    by Henry Wilkes at 2026-01-12T17:08:28+00:00
    fixup! TB 43901: Modify about:license for Tor Browser.
    
    TB 44420: Drop "rights" from components.conf.
    

17 changed files:

Changes:

  • browser/components/about/components.conf
    ... ... @@ -25,7 +25,6 @@ pages = [
    25 25
         'profiling',
    
    26 26
         'reader',
    
    27 27
         'restartrequired',
    
    28
    -    'rights',
    
    29 28
         # Removed 'rights'. tor-browser#43901.
    
    30 29
         # Removed 'robots'. tor-browser#42831.
    
    31 30
         'rulesets',
    

  • build/moz.configure/windows.configure
    ... ... @@ -628,12 +628,13 @@ with only_when(depends(c_compiler)(lambda c: c.type == "clang-cl")):
    628 628
             add_linker_flag("-LARGEADDRESSAWARE")
    
    629 629
             add_linker_flag("-SAFESEH")
    
    630 630
     
    
    631
    -        # avoid conficts with std::min/max
    
    632
    -        set_define("NOMINMAX", True)
    
    633
    -
    
    634 631
     
    
    635 632
     set_define("WIN32_LEAN_AND_MEAN", True)
    
    636 633
     
    
    634
    +with only_when(depends(c_compiler)(lambda c: c.type == "clang-cl")):
    
    635
    +    # See http://support.microsoft.com/kb/143208 to use STL
    
    636
    +    set_define("NOMINMAX", True)
    
    637
    +
    
    637 638
     
    
    638 639
     with only_when(target_is_windows & depends(c_compiler)(lambda c: c.type != "clang-cl")):
    
    639 640
         # strsafe.h on mingw uses macros for function deprecation that pollutes namespace
    

  • dom/media/webrtc/libwebrtc_overrides/modules/desktop_capture/desktop_capture_types.h
    ... ... @@ -7,11 +7,11 @@
    7 7
     #ifndef DOM_MEDIA_WEBRTC_LIBWEBRTCOVERRIDES_MODULES_DESKTOP_CAPTURE_DESKTOP_CAPTURE_TYPES_H_
    
    8 8
     #define DOM_MEDIA_WEBRTC_LIBWEBRTCOVERRIDES_MODULES_DESKTOP_CAPTURE_DESKTOP_CAPTURE_TYPES_H_
    
    9 9
     
    
    10
    -// pid_t
    
    11
    -#if !defined(XP_WIN) || defined(__MINGW32__)
    
    10
    +#if defined(XP_WIN) && \
    
    11
    +  !defined(__MINGW32__) // Moving this into the global namespace
    
    12
    +typedef int pid_t;      // matching what used to be in
    
    13
    +#elif defined(XP_WIN)  // video_capture_defines.h
    
    12 14
     #  include <sys/types.h>
    
    13
    -#else
    
    14
    -typedef int pid_t;
    
    15 15
     #endif
    
    16 16
     
    
    17 17
     #include "../../third_party/libwebrtc/modules/desktop_capture/desktop_capture_types.h"
    

  • python/mach/mach/sentry.py
    ... ... @@ -8,7 +8,7 @@ import sys
    8 8
     from pathlib import Path
    
    9 9
     from threading import Thread
    
    10 10
     
    
    11
    -# import sentry_sdk
    
    11
    +import sentry_sdk
    
    12 12
     from mozversioncontrol import (
    
    13 13
         InvalidRepoPath,
    
    14 14
         MissingUpstreamRepo,
    
    ... ... @@ -35,8 +35,7 @@ class SentryErrorReporter(ErrorReporter):
    35 35
         """Reports errors using Sentry."""
    
    36 36
     
    
    37 37
         def report_exception(self, exception):
    
    38
    -        pass
    
    39
    -        # return sentry_sdk.capture_exception(exception)
    
    38
    +        return sentry_sdk.capture_exception(exception)
    
    40 39
     
    
    41 40
     
    
    42 41
     class NoopErrorReporter(ErrorReporter):
    
    ... ... @@ -62,10 +61,10 @@ def register_sentry(argv, settings, topsrcdir: Path):
    62 61
         )
    
    63 62
         _is_unmodified_mach_core_thread.start()
    
    64 63
     
    
    65
    -    # sentry_sdk.init(
    
    66
    -    #     _SENTRY_DSN, before_send=lambda event, _: _process_event(event, topsrcdir)
    
    67
    -    # )
    
    68
    -    # sentry_sdk.add_breadcrumb(message="./mach {}".format(" ".join(argv)))
    
    64
    +    sentry_sdk.init(
    
    65
    +        _SENTRY_DSN, before_send=lambda event, _: _process_event(event, topsrcdir)
    
    66
    +    )
    
    67
    +    sentry_sdk.add_breadcrumb(message="./mach {}".format(" ".join(argv)))
    
    69 68
         return SentryErrorReporter()
    
    70 69
     
    
    71 70
     
    

  • python/mach/mach/telemetry.py
    ... ... @@ -7,9 +7,11 @@ import importlib.util
    7 7
     import os
    
    8 8
     import subprocess
    
    9 9
     import sys
    
    10
    +import urllib.parse as urllib_parse
    
    10 11
     from pathlib import Path
    
    11 12
     from textwrap import dedent
    
    12 13
     
    
    14
    +import requests
    
    13 15
     from mozbuild.base import BuildEnvironmentNotFoundException, MozbuildObject
    
    14 16
     from mozbuild.telemetry import filter_args
    
    15 17
     from mozfile import json
    
    ... ... @@ -90,7 +92,10 @@ def is_applicable_telemetry_environment():
    90 92
     
    
    91 93
     
    
    92 94
     def is_telemetry_enabled(settings):
    
    93
    -    return False
    
    95
    +    if os.environ.get("DISABLE_TELEMETRY") == "1":
    
    96
    +        return False
    
    97
    +
    
    98
    +    return settings.mach_telemetry.is_enabled
    
    94 99
     
    
    95 100
     
    
    96 101
     def arcrc_path():
    
    ... ... @@ -127,7 +132,40 @@ def resolve_setting_from_arcconfig(topsrcdir: Path, setting):
    127 132
     
    
    128 133
     
    
    129 134
     def resolve_is_employee_by_credentials(topsrcdir: Path):
    
    130
    -    return None
    
    135
    +    try:
    
    136
    +        phabricator_uri = resolve_setting_from_arcconfig(topsrcdir, "phabricator.uri")
    
    137
    +
    
    138
    +        if not phabricator_uri:
    
    139
    +            return None
    
    140
    +
    
    141
    +        with arcrc_path().open() as arcrc_file:
    
    142
    +            arcrc = json.load(arcrc_file)
    
    143
    +
    
    144
    +        phabricator_token = (
    
    145
    +            arcrc.get("hosts", {})
    
    146
    +            .get(urllib_parse.urljoin(phabricator_uri, "api/"), {})
    
    147
    +            .get("token")
    
    148
    +        )
    
    149
    +
    
    150
    +        if not phabricator_token:
    
    151
    +            return None
    
    152
    +
    
    153
    +        bmo_uri = (
    
    154
    +            resolve_setting_from_arcconfig(topsrcdir, "bmo_url")
    
    155
    +            or "https://bugzilla.mozilla.org"
    
    156
    +        )
    
    157
    +        bmo_api_url = urllib_parse.urljoin(bmo_uri, "rest/whoami")
    
    158
    +        bmo_result = requests.get(
    
    159
    +            bmo_api_url, headers={"X-PHABRICATOR-TOKEN": phabricator_token}
    
    160
    +        )
    
    161
    +
    
    162
    +        return "mozilla-employee-confidential" in bmo_result.json().get("groups", [])
    
    163
    +    except (
    
    164
    +        FileNotFoundError,
    
    165
    +        json.JSONDecodeError,
    
    166
    +        requests.exceptions.RequestException,
    
    167
    +    ):
    
    168
    +        return None
    
    131 169
     
    
    132 170
     
    
    133 171
     def resolve_is_employee_by_vcs(topsrcdir: Path):
    

  • python/mozbuild/mozbuild/action/test_archive.py
    ... ... @@ -229,6 +229,12 @@ ARCHIVE_FILES = {
    229 229
                 "pattern": "**",
    
    230 230
                 "dest": "certs",
    
    231 231
             },
    
    232
    +        {
    
    233
    +            "source": buildconfig.topsrcdir,
    
    234
    +            "base": "",
    
    235
    +            "pattern": "testing/tor",
    
    236
    +            "dest": "tor",
    
    237
    +        },
    
    232 238
         ],
    
    233 239
         "cppunittest": [
    
    234 240
             {"source": STAGE, "base": "", "pattern": "cppunittest/**"},
    

  • testing/marionette/harness/marionette_harness/tests/integration-tests.toml
    ... ... @@ -56,6 +56,10 @@
    56 56
     
    
    57 57
     ["include:../../../../../netwerk/test/marionette/manifest.toml"]
    
    58 58
     
    
    59
    +# tor tests
    
    60
    +
    
    61
    +["include:../../../../../testing/tor/manifest.toml"]
    
    62
    +
    
    59 63
     # toolkit tests
    
    60 64
     
    
    61 65
     ["include:../../../../../toolkit/components/antitracking/bouncetrackingprotection/test/marionette/manifest.toml"]
    

  • testing/moz.build
    ... ... @@ -24,4 +24,4 @@ PERFTESTS_MANIFESTS += [
    24 24
         "performance/perftest.toml",
    
    25 25
     ]
    
    26 26
     
    
    27
    -MARIONETTE_MANIFESTS += ["tor/marionette.toml"]
    27
    +MARIONETTE_MANIFESTS += ["tor/manifest.toml"]

  • testing/tor/marionette.tomltesting/tor/manifest.toml
    1 1
     [DEFAULT]
    
    2
    +tags = "tor"
    
    2 3
     
    
    3 4
     ["test_circuit_isolation.py"]
    
    4 5
     
    

  • testing/tor/test_circuit_isolation.py
    ... ... @@ -8,6 +8,9 @@ TOR_BOOTSTRAP_TIMEOUT = 30000 # 30s
    8 8
     
    
    9 9
     
    
    10 10
     class TestCircuitIsolation(MarionetteTestCase):
    
    11
    +    def tearDown(self):
    
    12
    +        self.marionette.restart(in_app=False, clean=True)
    
    13
    +        super(TestCircuitIsolation, self).tearDown()
    
    11 14
     
    
    12 15
         def bootstrap(self):
    
    13 16
             with self.marionette.using_context("chrome"):
    

  • testing/tor/test_network_check.py
    ... ... @@ -14,6 +14,10 @@ class TestNetworkCheck(MarionetteTestCase):
    14 14
     
    
    15 15
             self.l10n = L10n(self.marionette)
    
    16 16
     
    
    17
    +    def tearDown(self):
    
    18
    +        self.marionette.restart(in_app=False, clean=True)
    
    19
    +        super(TestNetworkCheck, self).tearDown()
    
    20
    +
    
    17 21
         def attemptConnection(self, tries=1):
    
    18 22
             if tries > 3:
    
    19 23
                 self.assertTrue(False, "Failed to connect to Tor after 3 attempts")
    

  • third_party/libwebrtc/modules/desktop_capture/win/wgc_capture_session.cc
    ... ... @@ -11,6 +11,8 @@
    11 11
     #include <dispatcherqueue.h>
    
    12 12
     #include <windows.graphics.capture.interop.h>
    
    13 13
     #include <windows.graphics.directx.direct3d11.interop.h>
    
    14
    +#include <windows.graphics.h>
    
    15
    +#include <wrl/client.h>
    
    14 16
     #include <wrl/event.h>
    
    15 17
     
    
    16 18
     #include <algorithm>
    

  • third_party/libwebrtc/modules/desktop_capture/win/window_capture_utils.h
    ... ... @@ -11,7 +11,7 @@
    11 11
     #ifndef MODULES_DESKTOP_CAPTURE_WIN_WINDOW_CAPTURE_UTILS_H_
    
    12 12
     #define MODULES_DESKTOP_CAPTURE_WIN_WINDOW_CAPTURE_UTILS_H_
    
    13 13
     
    
    14
    -#include <shlobj_core.h>
    
    14
    +#include <shlobj.h>
    
    15 15
     #include <windows.h>
    
    16 16
     #include <wrl/client.h>
    
    17 17
     
    

  • third_party/libwebrtc/rtc_base/cpu_info.cc
    ... ... @@ -97,7 +97,7 @@ uint64_t xgetbv(uint32_t xcr) {
    97 97
     }
    
    98 98
     #endif  // WEBRTC_ENABLE_AVX2
    
    99 99
     
    
    100
    -#ifndef _MSC_VER
    
    100
    +#if !defined(_MSC_VER) && !defined(__MINGW32__)
    
    101 101
     // Intrinsic for "cpuid".
    
    102 102
     #if defined(__pic__) && defined(__i386__)
    
    103 103
     static inline void __cpuid(int cpu_info[4], int info_type) {
    

  • third_party/libwebrtc/rtc_base/win/create_direct3d_device.h
    ... ... @@ -12,9 +12,8 @@
    12 12
     #define RTC_BASE_WIN_CREATE_DIRECT3D_DEVICE_H_
    
    13 13
     
    
    14 14
     #include <windows.graphics.directx.direct3d11.h>
    
    15
    -#ifndef __MINGW32__
    
    16
    -#  include <windows.graphics.directX.direct3d11.interop.h>
    
    17
    -#else
    
    15
    +#include <windows.graphics.directx.direct3d11.interop.h>
    
    16
    +#ifdef __MINGW32__
    
    18 17
     #  include <dxgi.h>
    
    19 18
     #  include <inspectable.h>
    
    20 19
     extern "C" {
    
    ... ... @@ -23,6 +22,7 @@ HRESULT __stdcall CreateDirect3D11DeviceFromDXGIDevice(
    23 22
         ::IDXGIDevice* dxgiDevice, ::IInspectable** graphicsDevice);
    
    24 23
     }
    
    25 24
     #endif
    
    25
    +
    
    26 26
     #include <winerror.h>
    
    27 27
     #include <wrl/client.h>
    
    28 28
     
    

  • toolkit/content/pt_config.json
    ... ... @@ -25,4 +25,3 @@
    25 25
         ]
    
    26 26
       }
    
    27 27
     }
    28
    -

  • tools/base_browser/tb-dev
    ... ... @@ -6,6 +6,7 @@ Useful tools for working on tor-browser repository.
    6 6
     
    
    7 7
     import argparse
    
    8 8
     import atexit
    
    9
    +import functools
    
    9 10
     import json
    
    10 11
     import os
    
    11 12
     import re
    
    ... ... @@ -14,8 +15,15 @@ import sys
    14 15
     import tempfile
    
    15 16
     import termios
    
    16 17
     import urllib.request
    
    18
    +from collections.abc import Callable, Iterable, Iterator
    
    19
    +from types import ModuleType
    
    20
    +from typing import Any, NotRequired, TypedDict, TypeVar
    
    17 21
     
    
    18
    -import argcomplete
    
    22
    +argcomplete: None | ModuleType = None
    
    23
    +try:
    
    24
    +    import argcomplete
    
    25
    +except ImportError:
    
    26
    +    pass
    
    19 27
     
    
    20 28
     GIT_PATH = "/usr/bin/git"
    
    21 29
     UPSTREAM_URLS = {
    
    ... ... @@ -36,9 +44,14 @@ class TbDevException(Exception):
    36 44
         pass
    
    37 45
     
    
    38 46
     
    
    39
    -def git_run(args, check=True, env=None):
    
    47
    +def git_run(
    
    48
    +    args: list[str], check: bool = True, env: None | dict[str, str] = None
    
    49
    +) -> None:
    
    40 50
         """
    
    41 51
         Run a git command with output sent to stdout.
    
    52
    +    :param args: The arguments to pass to git.
    
    53
    +    :param check: Whether to check for success.
    
    54
    +    :param env: Optional environment to set.
    
    42 55
         """
    
    43 56
         if env is not None:
    
    44 57
             tmp_env = dict(os.environ)
    
    ... ... @@ -51,46 +64,122 @@ def git_run(args, check=True, env=None):
    51 64
             raise TbDevException(str(err)) from err
    
    52 65
     
    
    53 66
     
    
    54
    -def git_get(args):
    
    67
    +def git_run_pager(
    
    68
    +    args: list[str] | None = None,
    
    69
    +    arg_sequence: Iterable[list[str]] | None = None,
    
    70
    +    pager_prefix: None | str = None,
    
    71
    +) -> None:
    
    55 72
         """
    
    56
    -    Run a git command with each non-empty line returned in a list.
    
    73
    +    Run a sequence of git commands with the output concatenated and sent to the
    
    74
    +    git pager.
    
    75
    +    :param args: The arguments to pass to git, or `None` if a sequence is desired.
    
    76
    +    :param arg_sequence: A sequence representing several git commands.
    
    77
    +    :param pager_prefix: An optional text to send to the pager first.
    
    78
    +    """
    
    79
    +    if arg_sequence is None:
    
    80
    +        if args is not None:
    
    81
    +            arg_sequence = (args,)
    
    82
    +        else:
    
    83
    +            raise ValueError("Missing `arg_sequence` or `args`")
    
    84
    +    elif args is not None:
    
    85
    +        raise ValueError("Unexpected both args and arg_sequence")
    
    86
    +
    
    87
    +    pager = git_get(["var", "GIT_PAGER"])
    
    88
    +    if not pager:
    
    89
    +        raise TbDevException("Missing a GIT_PAGER")
    
    90
    +    command = [pager]
    
    91
    +    if os.path.basename(pager) == "less":
    
    92
    +        # Show colours.
    
    93
    +        command.append("-R")
    
    94
    +
    
    95
    +    pager_process = subprocess.Popen(command, stdin=subprocess.PIPE, text=True)
    
    96
    +    assert pager_process.stdin is not None
    
    97
    +
    
    98
    +    if pager_prefix is not None:
    
    99
    +        pager_process.stdin.write(pager_prefix)
    
    100
    +        pager_process.stdin.flush()
    
    101
    +
    
    102
    +    for git_args in arg_sequence:
    
    103
    +        subprocess.run(
    
    104
    +            [GIT_PATH, "--no-pager", *git_args], check=False, stdout=pager_process.stdin
    
    105
    +        )
    
    106
    +
    
    107
    +    pager_process.stdin.close()
    
    108
    +
    
    109
    +    status = pager_process.wait()
    
    110
    +    if status != 0:
    
    111
    +        raise TbDevException(f"git pager {pager} exited with status {status}")
    
    112
    +
    
    113
    +
    
    114
    +def git_get(args: list[str], strip: bool = True, check: bool = True) -> str:
    
    115
    +    """
    
    116
    +    Return the output from a git command.
    
    117
    +    :param args: The arguments to send to git.
    
    118
    +    :param strip: Whether to strip the whitespace from the output.
    
    119
    +    :param check: Whether to check for success.
    
    120
    +    :returns: The stdout.
    
    57 121
         """
    
    58 122
         try:
    
    59 123
             git_process = subprocess.run(
    
    60
    -            [GIT_PATH, *args], text=True, stdout=subprocess.PIPE, check=True
    
    124
    +            [GIT_PATH, *args], text=True, stdout=subprocess.PIPE, check=check
    
    61 125
             )
    
    62 126
         except subprocess.CalledProcessError as err:
    
    63 127
             raise TbDevException(str(err)) from err
    
    64
    -    return [line for line in git_process.stdout.split("\n") if line]
    
    128
    +    ret = git_process.stdout
    
    129
    +    if strip:
    
    130
    +        ret = ret.strip()
    
    131
    +    return ret
    
    65 132
     
    
    66 133
     
    
    67
    -local_root = None
    
    134
    +def git_lines(args: list[str]) -> Iterator[str]:
    
    135
    +    """
    
    136
    +    Yields the non-empty lines returned by the git command.
    
    137
    +    :param args: The arguments to send to git.
    
    138
    +    :yield: The lines.
    
    139
    +    """
    
    140
    +    for line in git_get(args, strip=False).split("\n"):
    
    141
    +        if not line:
    
    142
    +            continue
    
    143
    +        yield line
    
    144
    +
    
    145
    +
    
    146
    +def git_path_args(path_iter: Iterable[str]) -> Iterator[str]:
    
    147
    +    """
    
    148
    +    Generate the trailing arguments to specify paths in git commands, includes
    
    149
    +    the "--" separator just before the paths.
    
    150
    +    :param path_iter: The paths that should be passed in.
    
    151
    +    :yields: The git arguments.
    
    152
    +    """
    
    153
    +    yield "--"
    
    154
    +    for path in path_iter:
    
    155
    +        yield f":(literal){path}"
    
    68 156
     
    
    69 157
     
    
    70
    -def get_local_root():
    
    158
    +@functools.cache
    
    159
    +def get_local_root() -> str:
    
    71 160
         """
    
    72 161
         Get the path for the tor-browser root directory.
    
    162
    +    :returns: The local root.
    
    73 163
         """
    
    74
    -    global local_root
    
    75
    -    if local_root is None:
    
    76
    -        try:
    
    77
    -            # Make sure we have a matching remote in this git repository.
    
    78
    -            if get_upstream_details()["is-browser-repo"]:
    
    79
    -                local_root = git_get(["rev-parse", "--show-toplevel"])[0]
    
    80
    -            else:
    
    81
    -                local_root = ""
    
    82
    -        except TbDevException:
    
    83
    -            local_root = ""
    
    84
    -    return local_root
    
    164
    +    try:
    
    165
    +        # Make sure we have a matching remote in this git repository.
    
    166
    +        if get_upstream_details()["is-browser-repo"] == "True":
    
    167
    +            return git_get(["rev-parse", "--show-toplevel"])
    
    168
    +        else:
    
    169
    +            return ""
    
    170
    +    except TbDevException:
    
    171
    +        return ""
    
    85 172
     
    
    86 173
     
    
    87
    -def determine_upstream_details():
    
    174
    +@functools.cache
    
    175
    +def get_upstream_details() -> dict[str, str]:
    
    88 176
         """
    
    89
    -    Determine details about the upstream.
    
    177
    +    Get details about the upstream repository.
    
    178
    +    :returns: The details.
    
    90 179
         """
    
    91 180
         remote_urls = {
    
    92
    -        remote: git_get(["remote", "get-url", remote])[0]
    
    93
    -        for remote in git_get(["remote"])
    
    181
    +        remote: git_get(["remote", "get-url", remote])
    
    182
    +        for remote in git_lines(["remote"])
    
    94 183
         }
    
    95 184
     
    
    96 185
         matches = {
    
    ... ... @@ -102,7 +191,7 @@ def determine_upstream_details():
    102 191
         }
    
    103 192
     
    
    104 193
         is_browser_repo = len(matches) > 0
    
    105
    -    details = {"is-browser-repo": is_browser_repo}
    
    194
    +    details = {"is-browser-repo": str(is_browser_repo)}
    
    106 195
     
    
    107 196
         origin_remote_repo = matches.get("origin", None)
    
    108 197
         upstream_remote_repo = matches.get("upstream", None)
    
    ... ... @@ -125,31 +214,30 @@ def determine_upstream_details():
    125 214
         return details
    
    126 215
     
    
    127 216
     
    
    128
    -cached_upstream_details = None
    
    129
    -
    
    130
    -
    
    131
    -def get_upstream_details():
    
    132
    -    """
    
    133
    -    Get details about the upstream repository.
    
    134
    -    """
    
    135
    -    global cached_upstream_details
    
    136
    -    if cached_upstream_details is None:
    
    137
    -        cached_upstream_details = determine_upstream_details()
    
    138
    -    return cached_upstream_details
    
    139
    -
    
    140
    -
    
    141 217
     class Reference:
    
    142 218
         """Represents a git reference to a commit."""
    
    143 219
     
    
    144
    -    def __init__(self, name, commit):
    
    145
    -        self.name = name
    
    220
    +    _REFS_REGEX = re.compile(r"refs/[a-z]+/")
    
    221
    +
    
    222
    +    def __init__(self, full_name: str, commit: str) -> None:
    
    223
    +        """
    
    224
    +        :param full_name: The full reference name. E.g. "refs/tags/MyTag".
    
    225
    +        :param commit: The commit hash for the commit this reference points to.
    
    226
    +        """
    
    227
    +        match = self.__class__._REFS_REGEX.match(full_name)
    
    228
    +        if not match:
    
    229
    +            raise ValueError(f"Invalid reference name {full_name}")
    
    230
    +        self.full_name = full_name
    
    231
    +        self.name = full_name[match.end() :]
    
    146 232
             self.commit = commit
    
    147 233
     
    
    148 234
     
    
    149
    -def get_refs(ref_type, name_start):
    
    235
    +def get_refs(ref_type: str, name_start: str) -> Iterator[Reference]:
    
    150 236
         """
    
    151
    -    Get a list of references that match the given 'ref_type' ("tag" or "remote"
    
    152
    -    or "head") that starts with the given 'name_start'.
    
    237
    +    Get a list of references that match the given conditions.
    
    238
    +    :param ref_type: The ref type to search for ("tag" or "remote" or "head").
    
    239
    +    :param name_start: The ref name start to match against.
    
    240
    +    :yield: The matching references.
    
    153 241
         """
    
    154 242
         if ref_type == "tag":
    
    155 243
             ref_start = "refs/tags/"
    
    ... ... @@ -163,56 +251,83 @@ def get_refs(ref_type, name_start):
    163 251
         fstring = "%(*objectname),%(objectname),%(refname)"
    
    164 252
         pattern = f"{ref_start}{name_start}**"
    
    165 253
     
    
    166
    -    def line_to_ref(line):
    
    254
    +    def line_to_ref(line: str) -> Reference:
    
    167 255
             [objectname_reference, objectname, ref_name] = line.split(",", 2)
    
    168 256
             # For annotated tags, the objectname_reference is non-empty and points
    
    169 257
             # to an actual commit.
    
    170 258
             # For remotes, heads and lightweight tags, the objectname_reference will
    
    171 259
             # be empty and objectname will point directly to the commit.
    
    172
    -        return Reference(
    
    173
    -            ref_name.replace(ref_start, "", 1), objectname_reference or objectname
    
    174
    -        )
    
    260
    +        return Reference(ref_name, objectname_reference or objectname)
    
    175 261
     
    
    176
    -    return [
    
    262
    +    return (
    
    177 263
             line_to_ref(line)
    
    178
    -        for line in git_get(["for-each-ref", f"--format={fstring}", pattern])
    
    179
    -    ]
    
    264
    +        for line in git_lines(["for-each-ref", f"--format={fstring}", pattern])
    
    265
    +    )
    
    180 266
     
    
    181 267
     
    
    182
    -def get_nearest_ref(ref_type, name_start, search_from):
    
    268
    +def get_firefox_ref(search_from: str) -> Reference:
    
    183 269
         """
    
    184
    -    Search backwards from the 'search_from' commit to find the first commit
    
    185
    -    that matches the given 'ref_type' that starts with the given 'name_start'.
    
    270
    +    Search for the commit that comes from firefox.
    
    271
    +    :param search_from: The commit to search backwards from.
    
    272
    +    :returns: The firefox reference.
    
    186 273
         """
    
    187
    -    ref_list = get_refs(ref_type, name_start)
    
    274
    +    # Only search a limited history that should include the FIREFOX_ tag.
    
    275
    +    search_commits = [c for c in git_lines(["rev-list", "-1000", search_from])]
    
    276
    +
    
    277
    +    firefox_tag_prefix = "FIREFOX_"
    
    188 278
     
    
    189
    -    for commit in git_get(["rev-list", "-1000", search_from]):
    
    190
    -        for ref in ref_list:
    
    279
    +    existing_tags = list(get_refs("tag", firefox_tag_prefix))
    
    280
    +    for commit in search_commits:
    
    281
    +        for ref in existing_tags:
    
    191 282
                 if commit == ref.commit:
    
    192 283
                     return ref
    
    193 284
     
    
    194
    -    raise TbDevException(f"No {name_start} commit found in the last 1000 commits")
    
    195
    -
    
    196
    -
    
    197
    -def get_firefox_ref(search_from):
    
    285
    +    # Might just need to fetch tags from the remote.
    
    286
    +    upstream = get_upstream_details().get("remote", None)
    
    287
    +    if upstream:
    
    288
    +        remote_ref: None | Reference = None
    
    289
    +        search_index = len(search_commits)
    
    290
    +        # Search the remote for a tag that is in our history.
    
    291
    +        # We want to avoid triggering a long fetch, so we just want to grab the
    
    292
    +        # tag that already points to a commit in our history.
    
    293
    +        for line in git_lines(
    
    294
    +            ["ls-remote", upstream, f"refs/tags/{firefox_tag_prefix}*"]
    
    295
    +        ):
    
    296
    +            objectname, name = line.split("\t", 1)
    
    297
    +            for index in range(search_index):
    
    298
    +                if search_commits[index] == objectname:
    
    299
    +                    # Remove trailing "^{}" for commits pointed to by
    
    300
    +                    # annotated tags.
    
    301
    +                    remote_ref = Reference(re.sub(r"\^\{\}$", "", name), objectname)
    
    302
    +                    # Only continue to search for references that are even
    
    303
    +                    # closer to `search_from`.
    
    304
    +                    search_index = index
    
    305
    +                    break
    
    306
    +        if remote_ref is not None:
    
    307
    +            # Get a local copy of just this tag.
    
    308
    +            git_run(["fetch", "--no-tags", upstream, "tag", remote_ref.name])
    
    309
    +            return ref
    
    310
    +
    
    311
    +    raise TbDevException("Unable to find FIREFOX_ tag")
    
    312
    +
    
    313
    +
    
    314
    +def get_upstream_tracking_branch(search_from: str) -> str:
    
    198 315
         """
    
    199
    -    Search backwards from the 'search_from' commit to find the commit that comes
    
    200
    -    from firefox.
    
    316
    +    :param search_from: The commit reference.
    
    317
    +    :returns: The upstream branch reference name.
    
    201 318
         """
    
    202
    -    return get_nearest_ref("tag", "FIREFOX_", search_from)
    
    203
    -
    
    204
    -
    
    205
    -def get_upstream_tracking_branch(search_from):
    
    206
    -    return git_get(["rev-parse", "--abbrev-ref", f"{search_from}@{{upstream}}"])[0]
    
    319
    +    return git_get(["rev-parse", "--abbrev-ref", f"{search_from}@{{upstream}}"])
    
    207 320
     
    
    208 321
     
    
    209
    -def get_upstream_basis_commit(search_from):
    
    322
    +def get_upstream_basis_commit(search_from: str) -> str:
    
    210 323
         """
    
    211 324
         Get the first common ancestor of search_from that is also in its upstream
    
    212 325
         branch.
    
    326
    +    :param search_from: The commit reference.
    
    327
    +    :returns: The upstream commit hash.
    
    213 328
         """
    
    214 329
         upstream_branch = get_upstream_tracking_branch(search_from)
    
    215
    -    commit = git_get(["merge-base", search_from, upstream_branch])[0]
    
    330
    +    commit = git_get(["merge-base", search_from, upstream_branch])
    
    216 331
         # Verify that the upstream commit shares the same firefox basis. Otherwise,
    
    217 332
         # this would indicate that the upstream is on an early or later FIREFOX
    
    218 333
         # base.
    
    ... ... @@ -226,26 +341,82 @@ def get_upstream_basis_commit(search_from):
    226 341
         return commit
    
    227 342
     
    
    228 343
     
    
    229
    -def get_changed_files(from_commit, staged=False):
    
    344
    +class FileChange:
    
    345
    +    """Represents a git change to a commit."""
    
    346
    +
    
    347
    +    def __init__(self, status: str, path: str, new_path: str) -> None:
    
    348
    +        """
    
    349
    +        :param status: The file change status used within git diff. E.g. "M" for
    
    350
    +          modified, or "D" for deleted.
    
    351
    +        :param path: The source file path.
    
    352
    +        :param new_path: The file path after the change.
    
    353
    +        """
    
    354
    +        self.status = status
    
    355
    +        self.path = path
    
    356
    +        self.new_path = new_path
    
    357
    +
    
    358
    +
    
    359
    +RAW_DIFF_PATH_PATTERN = r"(?P<path>[^\0]*)\0"
    
    360
    +RAW_DIFF_LINE_REGEX = re.compile(
    
    361
    +    r":[0-7]+ [0-7]+ [0-9a-f]+ [0-9a-f]+ (?P<status>[ADMTUXRC])[0-9]*\0"
    
    362
    +    + RAW_DIFF_PATH_PATTERN
    
    363
    +)
    
    364
    +RAW_DIFF_PATH_REGEX = re.compile(RAW_DIFF_PATH_PATTERN)
    
    365
    +
    
    366
    +
    
    367
    +def parse_raw_diff_line(raw_output: str) -> tuple[FileChange, int]:
    
    230 368
         """
    
    231
    -    Get a list of filenames relative to the current working directory that have
    
    369
    +    Parse the --raw diff output from git.
    
    370
    +    :param raw_output: The raw output.
    
    371
    +    :returns: The change for this line, and the offset for the end of the raw
    
    372
    +      diff line.
    
    373
    +    """
    
    374
    +    match = RAW_DIFF_LINE_REGEX.match(raw_output)
    
    375
    +    if not match:
    
    376
    +        raise ValueError(f"Invalid raw output: {raw_output[:50]}...")
    
    377
    +    path = os.path.relpath(os.path.join(get_local_root(), match.group("path")))
    
    378
    +    status = match.group("status")
    
    379
    +    if status in ("R", "C"):
    
    380
    +        match = RAW_DIFF_PATH_REGEX.match(raw_output, pos=match.end())
    
    381
    +        if not match:
    
    382
    +            raise ValueError(f"Invalid raw output for rename: {raw_output[:50]}...")
    
    383
    +        new_path = os.path.relpath(os.path.join(get_local_root(), match.group("path")))
    
    384
    +    else:
    
    385
    +        new_path = path
    
    386
    +
    
    387
    +    return FileChange(status, path, new_path), match.end()
    
    388
    +
    
    389
    +
    
    390
    +def get_changed_files(
    
    391
    +    from_commit: None | str = None, staged: bool = False
    
    392
    +) -> Iterator[FileChange]:
    
    393
    +    """
    
    394
    +    Get a list of file changes relative to the current working directory that have
    
    232 395
         been changed since 'from_commit' (non-inclusive).
    
    396
    +    :param from_commit: The commit to compare against, otherwise use the git
    
    397
    +      diff default.
    
    398
    +    :param staged: Whether to limit the diff to staged changes.
    
    399
    +    :yield: The file changes.
    
    233 400
         """
    
    234
    -    args = ["diff"]
    
    401
    +    args = ["diff", "-z", "--raw"]
    
    235 402
         if staged:
    
    236 403
             args.append("--staged")
    
    237
    -    args.append("--name-only")
    
    238
    -    args.append(from_commit)
    
    239
    -    return [
    
    240
    -        os.path.relpath(os.path.join(get_local_root(), filename))
    
    241
    -        for filename in git_get(args)
    
    242
    -    ]
    
    404
    +    if from_commit:
    
    405
    +        args.append(from_commit)
    
    406
    +    raw_output = git_get(args, strip=False)
    
    407
    +    while raw_output:
    
    408
    +        file_change, end = parse_raw_diff_line(raw_output)
    
    409
    +        yield file_change
    
    410
    +        raw_output = raw_output[end:]
    
    243 411
     
    
    244 412
     
    
    245
    -def file_contains(filename, regex):
    
    413
    +def file_contains(filename: str, regex: re.Pattern[str]) -> bool:
    
    246 414
         """
    
    247 415
         Return whether the file is a utf-8 text file containing the regular
    
    248 416
         _expression_ given by 'regex'.
    
    417
    +    :param filename: The file path.
    
    418
    +    :param regex: The pattern to search for.
    
    419
    +    :returns: Whether the pattern was matched.
    
    249 420
         """
    
    250 421
         with open(filename, encoding="utf-8") as file:
    
    251 422
             try:
    
    ... ... @@ -258,9 +429,10 @@ def file_contains(filename, regex):
    258 429
         return False
    
    259 430
     
    
    260 431
     
    
    261
    -def get_gitlab_default():
    
    432
    +def get_gitlab_default() -> str:
    
    262 433
         """
    
    263 434
         Get the name of the default branch on gitlab.
    
    435
    +    :returns: The branch name.
    
    264 436
         """
    
    265 437
         repo_name = get_upstream_details().get("repo-name", None)
    
    266 438
         if repo_name is None:
    
    ... ... @@ -283,12 +455,14 @@ def get_gitlab_default():
    283 455
         )
    
    284 456
     
    
    285 457
         with urllib.request.urlopen(gitlab_request, timeout=20) as response:
    
    286
    -        return json.load(response)["data"]["project"]["repository"]["rootRef"]
    
    458
    +        default = json.load(response)["data"]["project"]["repository"]["rootRef"]
    
    459
    +        assert isinstance(default, str)
    
    460
    +        return default
    
    287 461
     
    
    288 462
     
    
    289
    -def within_browser_root():
    
    463
    +def within_browser_root() -> bool:
    
    290 464
         """
    
    291
    -    Whether we are with the tor browser root.
    
    465
    +    :returns: Whether we are with the tor browser root.
    
    292 466
         """
    
    293 467
         root = get_local_root()
    
    294 468
         if not root:
    
    ... ... @@ -301,24 +475,24 @@ def within_browser_root():
    301 475
     # * -------------------- *
    
    302 476
     
    
    303 477
     
    
    304
    -def show_firefox_commit(_args):
    
    478
    +def show_firefox_commit(_args: argparse.Namespace) -> None:
    
    305 479
         """
    
    306 480
         Print the tag name and commit for the last firefox commit below the current
    
    307 481
         HEAD.
    
    308 482
         """
    
    309 483
         ref = get_firefox_ref("HEAD")
    
    310
    -    print(ref.name)
    
    484
    +    print(ref.full_name)
    
    311 485
         print(ref.commit)
    
    312 486
     
    
    313 487
     
    
    314
    -def show_upstream_basis_commit(_args):
    
    488
    +def show_upstream_basis_commit(_args: argparse.Namespace) -> None:
    
    315 489
         """
    
    316 490
         Print the last upstream commit for the current HEAD.
    
    317 491
         """
    
    318 492
         print(get_upstream_basis_commit("HEAD"))
    
    319 493
     
    
    320 494
     
    
    321
    -def show_log(args):
    
    495
    +def show_log(args: argparse.Namespace) -> None:
    
    322 496
         """
    
    323 497
         Show the git log between the current HEAD and the last firefox commit.
    
    324 498
         """
    
    ... ... @@ -326,7 +500,7 @@ def show_log(args):
    326 500
         git_run(["log", f"{commit}..HEAD", *args.gitargs], check=False)
    
    327 501
     
    
    328 502
     
    
    329
    -def show_files_containing(args):
    
    503
    +def show_files_containing(args: argparse.Namespace) -> None:
    
    330 504
         """
    
    331 505
         List all the files that that have been modified for tor browser, that also
    
    332 506
         contain a regular _expression_.
    
    ... ... @@ -336,33 +510,32 @@ def show_files_containing(args):
    336 510
         except re.error as err:
    
    337 511
             raise TbDevException(f"{args.regex} is not a valid python regex") from err
    
    338 512
     
    
    339
    -    file_list = get_changed_files(get_firefox_ref("HEAD").commit)
    
    340
    -
    
    341
    -    for filename in file_list:
    
    342
    -        if not os.path.isfile(filename):
    
    513
    +    for file_change in get_changed_files(get_firefox_ref("HEAD").commit):
    
    514
    +        path = file_change.new_path
    
    515
    +        if not os.path.isfile(path):
    
    343 516
                 # deleted ofile
    
    344 517
                 continue
    
    345
    -        if file_contains(filename, regex):
    
    346
    -            print(filename)
    
    518
    +        if file_contains(path, regex):
    
    519
    +            print(path)
    
    347 520
     
    
    348 521
     
    
    349
    -def show_changed_files(_args):
    
    522
    +def show_changed_files(_args: argparse.Namespace) -> None:
    
    350 523
         """
    
    351 524
         List all the files that have been modified relative to upstream.
    
    352 525
         """
    
    353
    -    for filename in get_changed_files(get_upstream_basis_commit("HEAD")):
    
    354
    -        print(filename)
    
    526
    +    for file_change in get_changed_files(get_upstream_basis_commit("HEAD")):
    
    527
    +        print(file_change.new_path)
    
    355 528
     
    
    356 529
     
    
    357
    -def lint_changed_files(args):
    
    530
    +def lint_changed_files(args: argparse.Namespace) -> None:
    
    358 531
         """
    
    359 532
         Lint all the files that have been modified relative to upstream.
    
    360 533
         """
    
    361 534
         os.chdir(get_local_root())
    
    362 535
         file_list = [
    
    363
    -        f
    
    536
    +        f.new_path
    
    364 537
             for f in get_changed_files(get_upstream_basis_commit("HEAD"))
    
    365
    -        if os.path.isfile(f)  # Not deleted
    
    538
    +        if os.path.isfile(f.new_path)  # Not deleted
    
    366 539
         ]
    
    367 540
         # We add --warnings since clang only reports whitespace issues as warnings.
    
    368 541
         subprocess.run(
    
    ... ... @@ -371,10 +544,18 @@ def lint_changed_files(args):
    371 544
         )
    
    372 545
     
    
    373 546
     
    
    374
    -def prompt_user(prompt, convert):
    
    547
    +# TODO: replace with "prompt_user[T](..., T]) -> T" after python 3.12 is the
    
    548
    +# minimum mach version.
    
    549
    +T = TypeVar("T")
    
    550
    +
    
    551
    +
    
    552
    +def prompt_user(prompt: str, convert: Callable[[str], T]) -> T:
    
    375 553
         """
    
    376
    -    Ask the user for some input until the given converter returns without
    
    377
    -    throwing a ValueError.
    
    554
    +    Ask the user for some input.
    
    555
    +    :param prompt: The prompt to show the user.
    
    556
    +    :param convert: A method to convert the response into a type. Should
    
    557
    +      throw `ValueError` if the user should be re-prompted for a valid input.
    
    558
    +    :returns: The first valid user response.
    
    378 559
         """
    
    379 560
         while True:
    
    380 561
             # Flush out stdin.
    
    ... ... @@ -388,8 +569,12 @@ def prompt_user(prompt, convert):
    388 569
                 pass
    
    389 570
     
    
    390 571
     
    
    391
    -def binary_reply_default_no(value):
    
    392
    -    """Process a 'y' or 'n' reply, defaulting to 'n' if empty."""
    
    572
    +def binary_reply_default_no(value: str) -> bool:
    
    573
    +    """
    
    574
    +    Process a 'y' or 'n' reply, defaulting to 'n' if empty.
    
    575
    +    :param value: The user input.
    
    576
    +    :returns: Whether the answer is yes.
    
    577
    +    """
    
    393 578
         if value == "":
    
    394 579
             return False
    
    395 580
         if value.lower() == "y":
    
    ... ... @@ -399,121 +584,737 @@ def binary_reply_default_no(value):
    399 584
         raise ValueError()
    
    400 585
     
    
    401 586
     
    
    402
    -def get_fixup_for_file(filename, firefox_commit):
    
    403
    -    """Find the commit the given file should fix up."""
    
    587
    +class FixupTarget:
    
    588
    +    """Represents a commit that can be targeted by a fixup."""
    
    589
    +
    
    590
    +    def __init__(self, commit: str, short_ref: str, title: str) -> None:
    
    591
    +        """
    
    592
    +        :param commit: The commit hash for the commit.
    
    593
    +        :param short_ref: The shortened commit hash for display.
    
    594
    +        :param title: The first line of the commit message.
    
    595
    +        """
    
    596
    +        self.commit = commit
    
    597
    +        self.short_ref = short_ref
    
    598
    +        self.title = title
    
    599
    +        self.changes: list[FileChange] = []
    
    600
    +        self.fixups: list[FixupTarget] = []
    
    601
    +        self.target: None | FixupTarget = None
    
    602
    +
    
    603
    +    _FIXUP_REGEX = re.compile(r"^fixup! +")
    
    604
    +
    
    605
    +    def trim_fixup(self) -> tuple[str, int]:
    
    606
    +        """
    
    607
    +        Trim the "fixup!" prefixes.
    
    608
    +        :returns: The stripped commit title and the fixup depth (how many fixups
    
    609
    +          prefixes there were).
    
    610
    +        """
    
    611
    +        title = self.title
    
    612
    +        depth = 0
    
    613
    +        while True:
    
    614
    +            match = self.__class__._FIXUP_REGEX.match(title)
    
    615
    +            if not match:
    
    616
    +                return title, depth
    
    617
    +            title = title[match.end() :]
    
    618
    +            depth += 1
    
    619
    +
    
    620
    +    def touches_path(
    
    621
    +        self, path: str, filter_status: None | str = None, check_dir: bool = False
    
    622
    +    ) -> bool:
    
    623
    +        """
    
    624
    +        Whether this target, or one of its fixups or target, touches the given
    
    625
    +        path.
    
    626
    +        :param path: The path to check.
    
    627
    +        :param filter_status: Limit the detected changes to the given status(es).
    
    628
    +        :param check_dir: Whether we should treat `path` as a directory and check for
    
    629
    +          files within it.
    
    630
    +        :returns: Whether this target matches.
    
    631
    +        """
    
    632
    +        # NOTE: In the case of renames, we generally assume that renames occur
    
    633
    +        # in the fixup targets. E.g. "Commit 1" creates the file "file.txt", and
    
    634
    +        # "fixup! Commit 1" renames it to "new.txt". In this case, if the
    
    635
    +        # FixupTarget for "Commit 1" is passed in "file.txt" it will match. And
    
    636
    +        # if it is passed in "new.txt" it will also match via the self.fixups
    
    637
    +        # field, which will include the "fixup! Commit 1" rename.
    
    638
    +        # But the "fixup ! Commit 1" FixupTargets will only match with
    
    639
    +        # "file.txt" if they occurred before the rename fixup, and will only
    
    640
    +        # match with "new.txt" if they occur after the rename fixup. With the
    
    641
    +        # exception of the rename fixup itself, which will match both.
    
    642
    +        #
    
    643
    +        # In principle, we could identify a file across renames (have a mapping
    
    644
    +        # from each commit to what the file is called at that stage) and match
    
    645
    +        # using this file identifier. Similar to the "--follow" git diff
    
    646
    +        # argument. This would then cover cases where a rename occurs between
    
    647
    +        # the commit and its fixups, and allow fixups before the rename to also
    
    648
    +        # match. However, the former case is unexpected and the latter case
    
    649
    +        # would not be that useful.
    
    650
    +        if self._touches_path_basis(path, filter_status, check_dir):
    
    651
    +            return True
    
    652
    +        # Mark this as a valid target for the path if one of our fixups changes
    
    653
    +        # this path.
    
    654
    +        # NOTE: We use _touch_path_basis to prevent recursion. This means we
    
    655
    +        # will only check one layer up or down, but we only expect fixups of
    
    656
    +        # up to depth 1.
    
    657
    +        for fixup_target in self.fixups:
    
    658
    +            if fixup_target._touches_path_basis(path, filter_status, check_dir):
    
    659
    +                return True
    
    660
    +        # Mark this as a valid target if our target changes this path.
    
    661
    +        if self.target is not None and self.target._touches_path_basis(
    
    662
    +            path, filter_status, check_dir
    
    663
    +        ):
    
    664
    +            return True
    
    665
    +        return False
    
    666
    +
    
    667
    +    def _touches_path_basis(
    
    668
    +        self, path: str, filter_status: None | str, check_dir: bool
    
    669
    +    ) -> bool:
    
    670
    +        """
    
    671
    +        Whether this target touches the given path.
    
    672
    +        :param path: The path to check.
    
    673
    +        :param filter_status: Limit the detected changes to the given status.
    
    674
    +        :param check_dir: Whether we should treat `path` as a directory and check for
    
    675
    +          files within it.
    
    676
    +        :returns: Whether this target matches.
    
    677
    +        """
    
    678
    +        for file_change in self.changes:
    
    679
    +            if filter_status is not None and file_change.status not in filter_status:
    
    680
    +                continue
    
    681
    +            for test_path in (file_change.path, file_change.new_path):
    
    682
    +                if check_dir:
    
    683
    +                    if os.path.commonpath((os.path.dirname(test_path), path)) == path:
    
    684
    +                        # test_path's directory matches the path or is within it.
    
    685
    +                        return True
    
    686
    +                elif test_path == path:
    
    687
    +                    return True
    
    688
    +        return False
    
    689
    +
    
    690
    +
    
    691
    +def get_fixup_targets(
    
    692
    +    target_list: list[FixupTarget],
    
    693
    +    from_commit: str,
    
    694
    +    to_commit: str,
    
    695
    +    fixup_depth: int = 0,
    
    696
    +) -> None:
    
    697
    +    """
    
    698
    +    Find all the commits that can be targeted by a fixup between the given
    
    699
    +    commits.
    
    700
    +    :param target_list: The list to fill with targets. Appended in the order of
    
    701
    +      `from_commit` to `to_commit`.
    
    702
    +    :param from_commit: The commit to start from (non-inclusive).
    
    703
    +    :param to_commit: The commit to end on (inclusive).
    
    704
    +    :param fixup_depth: The maximum "depth" of fixups. I.e. how many "fixup!"
    
    705
    +      prefixes to allow.
    
    706
    +    """
    
    707
    +    raw_output = git_get(
    
    708
    +        [
    
    709
    +            "log",
    
    710
    +            "--pretty=format:%H,%h,%s",
    
    711
    +            "--reverse",
    
    712
    +            "--raw",
    
    713
    +            "-z",
    
    714
    +            f"{from_commit}..{to_commit}",
    
    715
    +        ],
    
    716
    +        strip=False,
    
    717
    +    )
    
    718
    +    pretty_regex = re.compile(
    
    719
    +        r"(?P<commit>[0-9a-f]+),(?P<short_ref>[0-9a-f]+),(?P<title>[^\n\0]*)\n"
    
    720
    +    )
    
    721
    +    excluded_regex_list = [
    
    722
    +        re.compile(r"^Bug [0-9]+.*r="),  # Backported Mozilla bug.
    
    723
    +        re.compile(r"^dropme! "),
    
    724
    +    ]
    
    725
    +
    
    726
    +    while raw_output:
    
    727
    +        match = pretty_regex.match(raw_output)
    
    728
    +        if not match:
    
    729
    +            raise ValueError(f"Invalid pretty format: {raw_output[:100]}...")
    
    730
    +        fixup_target = FixupTarget(
    
    731
    +            match.group("commit"), match.group("short_ref"), match.group("title")
    
    732
    +        )
    
    733
    +        raw_output = raw_output[match.end() :]
    
    734
    +        while raw_output and raw_output[0] != "\0":
    
    735
    +            file_change, end = parse_raw_diff_line(raw_output)
    
    736
    +            fixup_target.changes.append(file_change)
    
    737
    +            raw_output = raw_output[end:]
    
    738
    +        if raw_output:
    
    739
    +            # Skip over the "\0".
    
    740
    +            raw_output = raw_output[1:]
    
    741
    +
    
    742
    +        for regex in excluded_regex_list:
    
    743
    +            if regex.match(fixup_target.title):
    
    744
    +                # Exclude from the list.
    
    745
    +                continue
    
    746
    +
    
    747
    +        trimmed_title, depth = fixup_target.trim_fixup()
    
    748
    +        if depth:
    
    749
    +            original_target = None
    
    750
    +            for target in target_list:
    
    751
    +                if target.title == trimmed_title:
    
    752
    +                    original_target = target
    
    753
    +                    break
    
    754
    +
    
    755
    +            if original_target:
    
    756
    +                original_target.fixups.append(fixup_target)
    
    757
    +                fixup_target.target = original_target
    
    758
    +                if depth > fixup_depth:
    
    759
    +                    # Exclude from the list.
    
    760
    +                    continue
    
    761
    +
    
    762
    +        target_list.append(fixup_target)
    
    763
    +
    
    764
    +
    
    765
    +class NewCommitBasis:
    
    766
    +    def __init__(self) -> None:
    
    767
    +        self.staged_paths: set[str] = set()
    
    768
    +        self.adding_paths: set[str] = set()
    
    769
    +
    
    770
    +    def add(self, paths: Iterable[str], staged: bool) -> None:
    
    771
    +        """
    
    772
    +        Add a path to include in this commit.
    
    773
    +        :param paths: The paths to add.
    
    774
    +        :param staged: Whether we are adding already staged changes.
    
    775
    +        """
    
    776
    +        if staged:
    
    777
    +            self.staged_paths.update(paths)
    
    778
    +            return
    
    779
    +
    
    780
    +        self.adding_paths.update(paths)
    
    781
    +
    
    782
    +
    
    783
    +class NewCommit(NewCommitBasis):
    
    784
    +    """Represents a new commit that we want to create."""
    
    404 785
     
    
    405
    -    def parse_log_line(line):
    
    406
    -        [commit, short_ref, title] = line.split(",", 2)
    
    407
    -        return {"commit": commit, "short-ref": short_ref, "title": title}
    
    786
    +    def __init__(self, alias: str) -> None:
    
    787
    +        """
    
    788
    +        :param alias: The alias name for the commit.
    
    789
    +        """
    
    790
    +        super().__init__()
    
    791
    +        self.alias = alias
    
    408 792
     
    
    409
    -    options = [
    
    410
    -        parse_log_line(line)
    
    411
    -        for line in git_get(
    
    412
    -            [
    
    413
    -                "log",
    
    414
    -                "--pretty=format:%H,%h,%s",
    
    415
    -                f"{firefox_commit}..HEAD",
    
    416
    -                "--",
    
    417
    -                filename,
    
    418
    -            ]
    
    793
    +
    
    794
    +class NewFixup(NewCommitBasis):
    
    795
    +    """Represents a new fixup commit that we want to create."""
    
    796
    +
    
    797
    +    def __init__(self, target: FixupTarget) -> None:
    
    798
    +        """
    
    799
    +        :param target: The commit to target with the fixup.
    
    800
    +        """
    
    801
    +        super().__init__()
    
    802
    +        self.target = target
    
    803
    +
    
    804
    +
    
    805
    +def get_suggested_fixup_targets_for_change(
    
    806
    +    file_change: FileChange,
    
    807
    +    fixup_target_list: list[FixupTarget],
    
    808
    +    firefox_directories_lazy: Callable[[], set[str]],
    
    809
    +) -> Iterator[FixupTarget]:
    
    810
    +    """
    
    811
    +    Find the suggested fixup targets for the given file change.
    
    812
    +    :param file_change: The file change to get a suggestion for.
    
    813
    +    :param fixup_target_list: The list to choose from.
    
    814
    +    :param firefox_directories_lazy: Lazy method to return the firefox
    
    815
    +      directories.
    
    816
    +    :yield: The suggested fixup targets.
    
    817
    +    """
    
    818
    +
    
    819
    +    def filter_list(
    
    820
    +        path: str, filter_status: None | str = None, check_dir: bool = False
    
    821
    +    ) -> Iterator[FixupTarget]:
    
    822
    +        return (
    
    823
    +            t
    
    824
    +            for t in fixup_target_list
    
    825
    +            if t.touches_path(path, filter_status=filter_status, check_dir=check_dir)
    
    419 826
             )
    
    827
    +
    
    828
    +    if file_change.status == "D":
    
    829
    +        # Deleted.
    
    830
    +        # Find the commit that introduced this file or previously deleted it.
    
    831
    +        # I.e. added the file ("A"), renamed it ("R"), or deleted it ("D").
    
    832
    +        yield from filter_list(file_change.path, filter_status="ARD")
    
    833
    +        return
    
    834
    +
    
    835
    +    if file_change.status == "A":
    
    836
    +        # First check to see if this file name was actually touched before.
    
    837
    +        yielded_target = False
    
    838
    +        for target in filter_list(file_change.path):
    
    839
    +            yielded_target = True
    
    840
    +            yield target
    
    841
    +        if yielded_target:
    
    842
    +            return
    
    843
    +        # Else, find commits that introduced files in the same directory, or
    
    844
    +        # deleted in them, if they are not firefox directories.
    
    845
    +        dir_path = file_change.path
    
    846
    +        while True:
    
    847
    +            dir_path = os.path.dirname(dir_path)
    
    848
    +            if not dir_path or dir_path in firefox_directories_lazy():
    
    849
    +                return
    
    850
    +
    
    851
    +            yielded_target = False
    
    852
    +            for target in filter_list(dir_path, filter_status="ARD", check_dir=True):
    
    853
    +                yielded_target = True
    
    854
    +                yield target
    
    855
    +
    
    856
    +            if yielded_target:
    
    857
    +                return
    
    858
    +            # Else, search one directory higher.
    
    859
    +
    
    860
    +    if file_change.status == "R":
    
    861
    +        # Renamed.
    
    862
    +        # Find the commit that introduced the original name for this file.
    
    863
    +        yield from filter_list(file_change.path, filter_status="AR")
    
    864
    +        return
    
    865
    +
    
    866
    +    # Modified.
    
    867
    +    yield from filter_list(file_change.path)
    
    868
    +
    
    869
    +
    
    870
    +def ask_for_target(
    
    871
    +    file_change_list: list[FileChange],
    
    872
    +    new_commits_list: list[NewCommit | NewFixup],
    
    873
    +    suggested_fixup_target_list: list[FixupTarget],
    
    874
    +    full_fixup_target_list: list[FixupTarget],
    
    875
    +    staged: bool = False,
    
    876
    +) -> bool:
    
    877
    +    """
    
    878
    +    Ask the user to choose a target.
    
    879
    +    :param file_change_list: The file changes to ask for.
    
    880
    +    :param new_commits_list: The list of pending new commits, may be added to.
    
    881
    +    :param suggested_fixup_target_list: The list of suggested target fixups
    
    882
    +      to choose from.
    
    883
    +    :param staged: Whether this is for staged changes.
    
    884
    +    :returns: `True` if the operation should be aborted.
    
    885
    +    """
    
    886
    +
    
    887
    +    new_paths = [c.new_path for c in file_change_list]
    
    888
    +    all_paths = set(new_paths).union(c.path for c in file_change_list)
    
    889
    +    non_fixup_commits: list[NewCommit] = [
    
    890
    +        n for n in new_commits_list if isinstance(n, NewCommit)
    
    420 891
         ]
    
    421
    -    if not options:
    
    422
    -        print(f"No commit found for {filename}")
    
    423
    -        return None
    
    424 892
     
    
    425
    -    def valid_index(val):
    
    893
    +    shown_list: list[NewCommit | FixupTarget] = (
    
    894
    +        non_fixup_commits + suggested_fixup_target_list
    
    895
    +    )
    
    896
    +
    
    897
    +    can_skip = not staged
    
    898
    +    shown_full = False
    
    899
    +
    
    900
    +    index_offset = 2
    
    901
    +
    
    902
    +    def valid_response(val: str) -> tuple[str, None | NewCommit | FixupTarget]:
    
    903
    +        val = val.strip()
    
    904
    +
    
    905
    +        if val == "h":
    
    906
    +            return "help", None
    
    907
    +
    
    908
    +        if val == "a":
    
    909
    +            return "abort", None
    
    910
    +
    
    426 911
             if val == "d":
    
    427
    -            return val
    
    912
    +            return "diff", None
    
    913
    +
    
    914
    +        if val == "f":
    
    915
    +            if shown_full:
    
    916
    +                # Already done once.
    
    917
    +                raise ValueError()
    
    918
    +            return "full-list", None
    
    428 919
     
    
    920
    +        is_patch_full = val.startswith("P")
    
    429 921
             is_patch = val.startswith("p")
    
    430
    -        if is_patch:
    
    431
    -            val = val[1:]
    
    922
    +        if is_patch or is_patch_full:
    
    923
    +            index = int(val[1:], base=10)  # Raises ValueError if not integer.
    
    924
    +        else:
    
    925
    +            index = int(val, base=10)  # Raises ValueError if not integer.
    
    926
    +            if index == 0:
    
    927
    +                if not can_skip:
    
    928
    +                    raise ValueError()
    
    929
    +                return "skip", None
    
    930
    +
    
    931
    +            if index == 1:
    
    932
    +                return "new", None
    
    432 933
     
    
    433
    -        # May raise a ValueError.
    
    434
    -        as_index = int(val)
    
    435
    -        if as_index < 0 or as_index > len(options):
    
    934
    +        index -= index_offset
    
    935
    +
    
    936
    +        if index < 0 or index >= len(shown_list):
    
    436 937
                 raise ValueError()
    
    437 938
     
    
    438
    -        if as_index == 0:
    
    439
    -            if is_patch:
    
    939
    +        selected = shown_list[index]
    
    940
    +
    
    941
    +        if is_patch_full:
    
    942
    +            return "patch-full", selected
    
    943
    +        if is_patch:
    
    944
    +            return "patch", selected
    
    945
    +        return "target", selected
    
    946
    +
    
    947
    +    def alias_response(val: str) -> str:
    
    948
    +        # Choose a default alias name if none is given.
    
    949
    +        val = val.strip() or f"New commit {len(non_fixup_commits)}"
    
    950
    +        for new_commit in non_fixup_commits:
    
    951
    +            if new_commit.alias == val:
    
    952
    +                # Already in use.
    
    440 953
                     raise ValueError()
    
    441
    -            return None
    
    954
    +        return val
    
    955
    +
    
    956
    +    def print_index_option(index: int, description: str) -> None:
    
    957
    +        print(f"  \x1b[1m{index}\x1b[0m: {description}")
    
    442 958
     
    
    443
    -        return (is_patch, options[as_index - 1]["commit"])
    
    959
    +    def in_pink(text: str) -> str:
    
    960
    +        return f"\x1b[1;38;5;212m{text}\x1b[0m"
    
    444 961
     
    
    962
    +    prefix_str = "For " + (in_pink("staged") if staged else "unstaged") + " changes to"
    
    963
    +    if len(new_paths) == 1:
    
    964
    +        print(f"{prefix_str} {in_pink(new_paths[0])}:")
    
    965
    +    else:
    
    966
    +        print(f"{prefix_str}:")
    
    967
    +        for path in new_paths:
    
    968
    +            print(f"  {in_pink(path)}")
    
    969
    +    print("")
    
    970
    +
    
    971
    +    show_help = True
    
    972
    +    reshow_list = True
    
    445 973
         while True:
    
    446
    -        print(f"For {filename}:\n")
    
    447
    -        print("  \x1b[1m0\x1b[0m: None")
    
    448
    -        for index, opt in enumerate(options):
    
    449
    -            print(
    
    450
    -                f"  \x1b[1m{index + 1}\x1b[0m: "
    
    451
    -                + f"\x1b[1;38;5;212m{opt['short-ref']}\x1b[0m "
    
    452
    -                + opt["title"]
    
    453
    -            )
    
    974
    +        if reshow_list:
    
    975
    +            if can_skip:
    
    976
    +                print_index_option(0, "Skip")
    
    977
    +            print_index_option(1, "New commit")
    
    978
    +            for index, target in enumerate(shown_list, start=index_offset):
    
    979
    +                if isinstance(target, NewCommit):
    
    980
    +                    print_index_option(index, f"Add to new commit: {target.alias}")
    
    981
    +                else:
    
    982
    +                    print_index_option(
    
    983
    +                        index, f"Fixup: {in_pink(target.short_ref)} {target.title}"
    
    984
    +                    )
    
    985
    +            reshow_list = False
    
    454 986
             print("")
    
    455
    -        response = prompt_user(
    
    456
    -            "Choose an <index> to fixup, or '0' to skip this file, "
    
    457
    -            "or 'd' to view the pending diff, "
    
    458
    -            "or 'p<index>' to view the patch for the index: ",
    
    459
    -            valid_index,
    
    987
    +
    
    988
    +        response, selected = prompt_user(
    
    989
    +            (
    
    990
    +                "Choose an <index> to target. Type 'h' for additional options: "
    
    991
    +                if show_help
    
    992
    +                else "Choose an <index> to target or an option: "
    
    993
    +            ),
    
    994
    +            valid_response,
    
    460 995
             )
    
    461
    -        if response is None:
    
    462
    -            # Skip this file.
    
    463
    -            return None
    
    464 996
     
    
    465
    -        if response == "d":
    
    466
    -            git_run(["diff", "--", filename])
    
    997
    +        if response == "help":
    
    998
    +            print("Options:")
    
    999
    +            for option, desc in (
    
    1000
    +                ("h", "show the available options."),
    
    1001
    +                ("a", "abort this commit operation and all pending commits."),
    
    1002
    +                (
    
    1003
    +                    ("", "")
    
    1004
    +                    if shown_full
    
    1005
    +                    else (
    
    1006
    +                        "f",
    
    1007
    +                        "show the full list of fixup targets, rather than just the suggested ones.",
    
    1008
    +                    )
    
    1009
    +                ),
    
    1010
    +                ("d", "view the diff for the pending file changes."),
    
    1011
    +                (
    
    1012
    +                    "P<index>",
    
    1013
    +                    "view the patch for the index (including its relevant fixups).",
    
    1014
    +                ),
    
    1015
    +                (
    
    1016
    +                    "p<index>",
    
    1017
    +                    "view the patch for the index (including its relevant fixups), "
    
    1018
    +                    "limited to the current files.",
    
    1019
    +                ),
    
    1020
    +            ):
    
    1021
    +                if not option:
    
    1022
    +                    # Skip this option.
    
    1023
    +                    continue
    
    1024
    +                print(f"  \x1b[1m{option[0]}\x1b[0m{option[1:].ljust(7)}: {desc}")
    
    1025
    +            # Do not show the help option again.
    
    1026
    +            show_help = False
    
    1027
    +            continue
    
    1028
    +
    
    1029
    +        if response == "abort":
    
    1030
    +            return True
    
    1031
    +
    
    1032
    +        if response == "skip":
    
    1033
    +            return False
    
    1034
    +
    
    1035
    +        if response == "new":
    
    1036
    +            new_alias = prompt_user(
    
    1037
    +                "Enter an optional temporary alias for this new commit: ",
    
    1038
    +                alias_response,
    
    1039
    +            )
    
    1040
    +            new_commit = NewCommit(new_alias)
    
    1041
    +            new_commit.add(all_paths, staged)
    
    1042
    +            new_commits_list.append(new_commit)
    
    1043
    +            return False
    
    1044
    +
    
    1045
    +        if response == "target":
    
    1046
    +            assert selected is not None
    
    1047
    +
    
    1048
    +            if isinstance(selected, NewCommit):
    
    1049
    +                # Adding to a new commit.
    
    1050
    +                selected.add(all_paths, staged)
    
    1051
    +                return False
    
    1052
    +
    
    1053
    +            for new_fixup in new_commits_list:
    
    1054
    +                if not isinstance(new_fixup, NewFixup):
    
    1055
    +                    continue
    
    1056
    +                if new_fixup.target == selected:
    
    1057
    +                    # We already have a pending fixup commit that targets this
    
    1058
    +                    # selected target. Add this path to the same commit.
    
    1059
    +                    new_fixup.add(all_paths, staged)
    
    1060
    +                    return False
    
    1061
    +
    
    1062
    +            new_fixup = NewFixup(selected)
    
    1063
    +            new_fixup.add(all_paths, staged)
    
    1064
    +            new_commits_list.append(new_fixup)
    
    1065
    +            return False
    
    1066
    +
    
    1067
    +        if response == "full-list":
    
    1068
    +            shown_list = non_fixup_commits + full_fixup_target_list
    
    1069
    +            shown_full = True
    
    1070
    +            reshow_list = True
    
    467 1071
                 continue
    
    468 1072
     
    
    469
    -        view_patch, commit = response
    
    470
    -        if view_patch:
    
    471
    -            git_run(["log", "-p", "-1", commit, "--", filename])
    
    1073
    +        if response == "diff":
    
    1074
    +            git_args = ["diff", "--color"]
    
    1075
    +            if staged:
    
    1076
    +                git_args.append("--staged")
    
    1077
    +            git_args.extend(git_path_args(all_paths))
    
    1078
    +            git_run_pager(git_args)
    
    472 1079
                 continue
    
    473 1080
     
    
    474
    -        return commit
    
    1081
    +        if response in ("patch", "patch-full"):
    
    1082
    +            assert selected is not None
    
    1083
    +
    
    1084
    +            filter_paths = response == "patch"
    
    1085
    +
    
    1086
    +            if isinstance(selected, NewCommit):
    
    1087
    +                git_sequence = [
    
    1088
    +                    ["diff", "--color", "--staged", *git_path_args((path,))]
    
    1089
    +                    for path in selected.staged_paths
    
    1090
    +                    if not filter_paths or path in all_paths
    
    1091
    +                ]
    
    1092
    +                git_sequence.extend(
    
    1093
    +                    ["diff", "--color", *git_path_args((path,))]
    
    1094
    +                    for path in selected.adding_paths
    
    1095
    +                    if not filter_paths or path in all_paths
    
    1096
    +                )
    
    1097
    +
    
    1098
    +                # Show what the expected patch will be for the new commit.
    
    1099
    +                git_run_pager(
    
    1100
    +                    arg_sequence=git_sequence, pager_prefix=f"{selected.alias}\n\n"
    
    1101
    +                )
    
    1102
    +            else:
    
    1103
    +                # Show the log entry for the FixupTarget and each of its fixups.
    
    1104
    +                # Order with the commmit closest to HEAD first. We expect
    
    1105
    +                # selected.fixups to match this order.
    
    1106
    +                git_sequence = []
    
    1107
    +                # If `filter_paths` is set, we want to limit the log to the
    
    1108
    +                # paths, and try to track any renames in the commit history.
    
    1109
    +                prev_log_paths: None | set[str] = None
    
    1110
    +                # For the first commit in the sequence, we use the old path
    
    1111
    +                # names (rather than `c.new_path`) since we expect the commit
    
    1112
    +                # which is closest to us to use the older names.
    
    1113
    +                log_paths: None | set[str] = (
    
    1114
    +                    {c.path for c in file_change_list} if filter_paths else None
    
    1115
    +                )
    
    1116
    +                for target in (*selected.fixups, selected):
    
    1117
    +                    git_args = [
    
    1118
    +                        "log",
    
    1119
    +                        "--color",
    
    1120
    +                        "-p",
    
    1121
    +                        f"{target.commit}~1..{target.commit}",
    
    1122
    +                    ]
    
    1123
    +                    if filter_paths:
    
    1124
    +                        assert log_paths is not None
    
    1125
    +                        # Track the renamed paths.
    
    1126
    +                        prev_log_paths = log_paths.copy()
    
    1127
    +                        for file_change in target.changes:
    
    1128
    +                            if (
    
    1129
    +                                file_change.status == "R"
    
    1130
    +                                and file_change.new_path in log_paths
    
    1131
    +                            ):
    
    1132
    +                                # file was renamed in this change.
    
    1133
    +                                # Update log_paths to the new name.
    
    1134
    +                                # NOTE: This should have a similar effect to the
    
    1135
    +                                # --follow option for git log for a single file
    
    1136
    +                                # NOTE: File renames will not be properly
    
    1137
    +                                # tracked if a rename occurs outside of
    
    1138
    +                                # `selected.changes` or
    
    1139
    +                                # `selected.fixups[].changes`, but this is
    
    1140
    +                                # unexpected.
    
    1141
    +                                log_paths.remove(file_change.new_path)
    
    1142
    +                                log_paths.add(file_change.path)
    
    1143
    +
    
    1144
    +                        # NOTE: This log entry may be empty if none of the paths
    
    1145
    +                        # match.
    
    1146
    +                        # NOTE: We include both log_paths and prev_log_paths to
    
    1147
    +                        # show renames in the diff output.
    
    1148
    +                        git_args.extend(git_path_args(log_paths | prev_log_paths))
    
    1149
    +                    git_sequence.append(git_args)
    
    1150
    +                # Combine all the logs into one.
    
    1151
    +                git_run_pager(arg_sequence=git_sequence)
    
    1152
    +            continue
    
    1153
    +
    
    1154
    +        raise ValueError(f"Unexpected response: {response}")
    
    475 1155
     
    
    476 1156
     
    
    477
    -def auto_fixup(_args):
    
    1157
    +def auto_commit(_args: argparse.Namespace) -> None:
    
    478 1158
         """
    
    479
    -    Automatically find and fix up commits using the current unstaged changes.
    
    1159
    +    Automatically find and fix up commits for any pending changes.
    
    480 1160
         """
    
    1161
    +    # Want git log and add to be run from the root.
    
    1162
    +    os.chdir(get_local_root())
    
    481 1163
         # Only want to search as far back as the firefox commit.
    
    482 1164
         firefox_commit = get_firefox_ref("HEAD").commit
    
    483 1165
     
    
    484
    -    staged_files = get_changed_files("HEAD", staged=True)
    
    485
    -    if staged_files:
    
    486
    -        raise TbDevException(f"Have already staged files: {staged_files}")
    
    1166
    +    staged_changes = [f for f in get_changed_files(staged=True)]
    
    1167
    +    if staged_changes:
    
    1168
    +        print("Existing staged changes for:")
    
    1169
    +        for file_change in staged_changes:
    
    1170
    +            print(f"  {file_change.new_path}")
    
    1171
    +        if not prompt_user(
    
    1172
    +            "Include staged changes? (y/\x1b[4mn\x1b[0m)", binary_reply_default_no
    
    1173
    +        ):
    
    1174
    +            raise TbDevException("Cannot continue with pending staged changes")
    
    1175
    +        print("")
    
    487 1176
     
    
    488
    -    fixups = {}
    
    489
    -    for filename in get_changed_files("HEAD"):
    
    490
    -        commit = get_fixup_for_file(filename, firefox_commit)
    
    491
    -        if commit is None:
    
    1177
    +    full_target_list: list[FixupTarget] = []
    
    1178
    +    # Determine if HEAD points to a branch or not and has an upstream commit.
    
    1179
    +    # We choose check=False since the exit status is non-zero when we are in a
    
    1180
    +    # detached state.
    
    1181
    +    head_symbolic_ref = git_get(["symbolic-ref", "-q", "HEAD"], check=False)
    
    1182
    +    if not head_symbolic_ref or not bool(
    
    1183
    +        git_get(["for-each-ref", "--format=%(upstream)", head_symbolic_ref])
    
    1184
    +    ):
    
    1185
    +        # Unexpected, but not fatal.
    
    1186
    +        print("HEAD has no upstream tracking!")
    
    1187
    +        # Just include all commits since firefox_commit with no fixup depth
    
    1188
    +        get_fixup_targets(full_target_list, firefox_commit, "HEAD", fixup_depth=0)
    
    1189
    +    else:
    
    1190
    +        upstream_commit = get_upstream_basis_commit("HEAD")
    
    1191
    +        # Only include "fixup!" commits that are between here and the upstream
    
    1192
    +        # tracking commit.
    
    1193
    +        get_fixup_targets(
    
    1194
    +            full_target_list, firefox_commit, upstream_commit, fixup_depth=0
    
    1195
    +        )
    
    1196
    +        get_fixup_targets(full_target_list, upstream_commit, "HEAD", fixup_depth=1)
    
    1197
    +
    
    1198
    +    # full_target_list is ordered with the earlier commits first. Reverse this.
    
    1199
    +    full_target_list.reverse()
    
    1200
    +    # Also reverse the fixups order to follow the same order.
    
    1201
    +    for target in full_target_list:
    
    1202
    +        target.fixups.reverse()
    
    1203
    +
    
    1204
    +    # Lazy load the list of firefox directories since they are unlikely to be
    
    1205
    +    # needed.
    
    1206
    +    @functools.cache
    
    1207
    +    def firefox_directories_lazy() -> set[str]:
    
    1208
    +        return {
    
    1209
    +            dir_name
    
    1210
    +            for dir_name in git_get(
    
    1211
    +                [
    
    1212
    +                    "ls-tree",
    
    1213
    +                    "-r",
    
    1214
    +                    "-d",
    
    1215
    +                    "--name-only",
    
    1216
    +                    "--full-tree",
    
    1217
    +                    "-z",
    
    1218
    +                    firefox_commit,
    
    1219
    +                ],
    
    1220
    +                strip=False,
    
    1221
    +            ).split("\0")
    
    1222
    +            if dir_name
    
    1223
    +        }
    
    1224
    +
    
    1225
    +    # Check untracked files to be added.
    
    1226
    +    for path in git_get(
    
    1227
    +        ["ls-files", "--other", "--exclude-standard", "-z"], strip=False
    
    1228
    +    ).split("\0"):
    
    1229
    +        if not path:
    
    492 1230
                 continue
    
    493
    -        if commit not in fixups:
    
    494
    -            fixups[commit] = [filename]
    
    495
    -        else:
    
    496
    -            fixups[commit].append(filename)
    
    1231
    +        if prompt_user(
    
    1232
    +            f"Start tracking file `{path}`? (y/\x1b[4mn\x1b[0m)",
    
    1233
    +            binary_reply_default_no,
    
    1234
    +        ):
    
    1235
    +            # Include in the git diff output, but do not stage.
    
    1236
    +            git_run(["add", "--intent-to-add", path])
    
    497 1237
             print("")
    
    498 1238
     
    
    499
    -    for commit, files in fixups.items():
    
    500
    -        print("")
    
    501
    -        git_run(["add", *files])
    
    502
    -        git_run(["commit", f"--fixup={commit}"])
    
    1239
    +    aborted = False
    
    1240
    +    new_commits_list: list[NewCommit | NewFixup] = []
    
    1241
    +    # First go through staged changes.
    
    1242
    +    if staged_changes:
    
    1243
    +        common_fixup_targets = None
    
    1244
    +        for change in staged_changes:
    
    1245
    +            target_iter = get_suggested_fixup_targets_for_change(
    
    1246
    +                change, full_target_list, firefox_directories_lazy
    
    1247
    +            )
    
    1248
    +            if common_fixup_targets is None:
    
    1249
    +                common_fixup_targets = set(target_iter)
    
    1250
    +            else:
    
    1251
    +                common_fixup_targets.intersection_update(target_iter)
    
    1252
    +
    
    1253
    +        assert common_fixup_targets is not None
    
    1254
    +
    
    1255
    +        aborted = ask_for_target(
    
    1256
    +            staged_changes,
    
    1257
    +            new_commits_list,
    
    1258
    +            # Sort in the same order as full_target_list.
    
    1259
    +            [target for target in full_target_list if target in common_fixup_targets],
    
    1260
    +            full_target_list,
    
    1261
    +            staged=True,
    
    1262
    +        )
    
    503 1263
             print("")
    
    504 1264
     
    
    505
    -        if prompt_user(
    
    506
    -            "Edit fixup commit message? (y/\x1b[4mn\x1b[0m)", binary_reply_default_no
    
    507
    -        ):
    
    1265
    +    if not aborted:
    
    1266
    +        for file_change in get_changed_files():
    
    1267
    +            target_list = list(
    
    1268
    +                get_suggested_fixup_targets_for_change(
    
    1269
    +                    file_change, full_target_list, firefox_directories_lazy
    
    1270
    +                )
    
    1271
    +            )
    
    1272
    +            aborted = ask_for_target(
    
    1273
    +                [file_change],
    
    1274
    +                new_commits_list,
    
    1275
    +                target_list,
    
    1276
    +                full_target_list,
    
    1277
    +                staged=False,
    
    1278
    +            )
    
    1279
    +            print("")
    
    1280
    +            if aborted:
    
    1281
    +                break
    
    1282
    +
    
    1283
    +    if aborted:
    
    1284
    +        return
    
    1285
    +
    
    1286
    +    # NOTE: Only the first commit can include staged changes.
    
    1287
    +    # This should already be the case, but we want to double check.
    
    1288
    +    for commit_index in range(1, len(new_commits_list)):
    
    1289
    +        if new_commits_list[commit_index].staged_paths:
    
    1290
    +            raise ValueError(f"Staged changes for commit {commit_index}")
    
    1291
    +
    
    1292
    +    for new_commit in new_commits_list:
    
    1293
    +        print("")
    
    1294
    +        if new_commit.adding_paths:
    
    1295
    +            git_run(["add", *git_path_args(new_commit.adding_paths)])
    
    1296
    +        if isinstance(new_commit, NewFixup):
    
    1297
    +            git_run(["commit", f"--fixup={new_commit.target.commit}"])
    
    1298
    +            print("")
    
    1299
    +            is_double_fixup = bool(new_commit.target.target)
    
    1300
    +            if not is_double_fixup and prompt_user(
    
    1301
    +                "Edit fixup commit message? (y/\x1b[4mn\x1b[0m)",
    
    1302
    +                binary_reply_default_no,
    
    1303
    +            ):
    
    1304
    +                git_run(["commit", "--amend"])
    
    1305
    +                print("")
    
    1306
    +        else:
    
    1307
    +            git_run(["commit", "-m", new_commit.alias])
    
    508 1308
                 git_run(["commit", "--amend"])
    
    1309
    +            print("")
    
    509 1310
     
    
    510 1311
     
    
    511
    -def clean_fixups(_args):
    
    1312
    +def clean_fixups(_args: argparse.Namespace) -> None:
    
    512 1313
         """
    
    513 1314
         Perform an interactive rebase that automatically applies fixups, similar to
    
    514 1315
         --autosquash but also works on fixups of fixups.
    
    515 1316
         """
    
    516
    -    user_editor = git_get(["var", "GIT_SEQUENCE_EDITOR"])[0]
    
    1317
    +    user_editor = git_get(["var", "GIT_SEQUENCE_EDITOR"])
    
    517 1318
         sub_editor = os.path.join(
    
    518 1319
             os.path.dirname(os.path.realpath(__file__)), FIXUP_PREPROCESSOR_EDITOR
    
    519 1320
         )
    
    ... ... @@ -525,7 +1326,7 @@ def clean_fixups(_args):
    525 1326
         )
    
    526 1327
     
    
    527 1328
     
    
    528
    -def show_default(_args):
    
    1329
    +def show_default(_args: argparse.Namespace) -> None:
    
    529 1330
         """
    
    530 1331
         Print the default branch name from gitlab.
    
    531 1332
         """
    
    ... ... @@ -536,7 +1337,7 @@ def show_default(_args):
    536 1337
         print(f"{upstream}/{default_branch}")
    
    537 1338
     
    
    538 1339
     
    
    539
    -def branch_from_default(args):
    
    1340
    +def branch_from_default(args: argparse.Namespace) -> None:
    
    540 1341
         """
    
    541 1342
         Fetch the default gitlab branch from upstream and create a new local branch.
    
    542 1343
         """
    
    ... ... @@ -557,7 +1358,7 @@ def branch_from_default(args):
    557 1358
         )
    
    558 1359
     
    
    559 1360
     
    
    560
    -def move_to_default(args):
    
    1361
    +def move_to_default(args: argparse.Namespace) -> None:
    
    561 1362
         """
    
    562 1363
         Fetch the default gitlab branch from upstream and move the specified
    
    563 1364
         branch's commits on top. A new branch will be created tracking the default
    
    ... ... @@ -569,7 +1370,7 @@ def move_to_default(args):
    569 1370
         if branch_name is None:
    
    570 1371
             # Use current branch as default.
    
    571 1372
             try:
    
    572
    -            branch_name = git_get(["branch", "--show-current"])[0]
    
    1373
    +            branch_name = git_get(["branch", "--show-current"])
    
    573 1374
             except IndexError:
    
    574 1375
                 raise TbDevException("No current branch")
    
    575 1376
     
    
    ... ... @@ -608,7 +1409,7 @@ def move_to_default(args):
    608 1409
         git_run(["cherry-pick", f"{current_basis}..{old_branch_name}"], check=False)
    
    609 1410
     
    
    610 1411
     
    
    611
    -def show_range_diff(args):
    
    1412
    +def show_range_diff(args: argparse.Namespace) -> None:
    
    612 1413
         """
    
    613 1414
         Show the range diff between two branches, from their firefox bases.
    
    614 1415
         """
    
    ... ... @@ -624,21 +1425,21 @@ def show_range_diff(args):
    624 1425
         )
    
    625 1426
     
    
    626 1427
     
    
    627
    -def show_diff_diff(args):
    
    1428
    +def show_diff_diff(args: argparse.Namespace) -> None:
    
    628 1429
         """
    
    629 1430
         Show the diff between the diffs of two branches, relative to their firefox
    
    630 1431
         bases.
    
    631 1432
         """
    
    632
    -    config_res = git_get(["config", "--get", "diff.tool"])
    
    633
    -    if not config_res:
    
    1433
    +    try:
    
    1434
    +        diff_tool = next(git_lines(["config", "--get", "diff.tool"]))
    
    1435
    +    except StopIteration:
    
    634 1436
             raise TbDevException("No diff.tool configured for git")
    
    635
    -    diff_tool = config_res[0]
    
    636 1437
     
    
    637 1438
         # Filter out parts of the diff we expect to be different.
    
    638 1439
         index_regex = re.compile(r"index [0-9a-f]{12}\.\.[0-9a-f]{12}")
    
    639 1440
         lines_regex = re.compile(r"@@ -[0-9]+,[0-9]+ \+[0-9]+,[0-9]+ @@(?P<rest>.*)")
    
    640 1441
     
    
    641
    -    def save_diff(branch):
    
    1442
    +    def save_diff(branch: str) -> str:
    
    642 1443
             firefox_commit = get_firefox_ref(branch).commit
    
    643 1444
             file_desc, file_name = tempfile.mkstemp(
    
    644 1445
                 text=True, prefix=f'{branch.split("/")[-1]}-'
    
    ... ... @@ -653,6 +1454,7 @@ def show_diff_diff(args):
    653 1454
             )
    
    654 1455
     
    
    655 1456
             with os.fdopen(file_desc, "w") as file:
    
    1457
    +            assert diff_process.stdout is not None
    
    656 1458
                 for line in diff_process.stdout:
    
    657 1459
                     if index_regex.match(line):
    
    658 1460
                         # Fake data that will match.
    
    ... ... @@ -665,7 +1467,7 @@ def show_diff_diff(args):
    665 1467
                         continue
    
    666 1468
                     file.write(line)
    
    667 1469
     
    
    668
    -        status = diff_process.poll()
    
    1470
    +        status = diff_process.wait()
    
    669 1471
             if status != 0:
    
    670 1472
                 raise TbDevException(f"git diff exited with status {status}")
    
    671 1473
     
    
    ... ... @@ -681,7 +1483,7 @@ def show_diff_diff(args):
    681 1483
     # * -------------------- *
    
    682 1484
     
    
    683 1485
     
    
    684
    -def branch_complete(prefix, parsed_args, **kwargs):
    
    1486
    +def branch_complete(prefix: str, **_kwargs: Any) -> list[str]:
    
    685 1487
         """
    
    686 1488
         Complete the argument with a branch name.
    
    687 1489
         """
    
    ... ... @@ -689,7 +1491,7 @@ def branch_complete(prefix, parsed_args, **kwargs):
    689 1491
             return []
    
    690 1492
         try:
    
    691 1493
             branches = [ref.name for ref in get_refs("head", "")]
    
    692
    -        branches.extend([ref.name for ref in get_refs("remote", "")])
    
    1494
    +        branches.extend(ref.name for ref in get_refs("remote", ""))
    
    693 1495
             branches.append("HEAD")
    
    694 1496
         except Exception:
    
    695 1497
             return []
    
    ... ... @@ -699,7 +1501,20 @@ def branch_complete(prefix, parsed_args, **kwargs):
    699 1501
     parser = argparse.ArgumentParser()
    
    700 1502
     subparsers = parser.add_subparsers(required=True)
    
    701 1503
     
    
    702
    -for name, details in {
    
    1504
    +
    
    1505
    +class ArgConfig(TypedDict):
    
    1506
    +    help: str
    
    1507
    +    metavar: NotRequired[str]
    
    1508
    +    nargs: NotRequired[str]
    
    1509
    +    completer: NotRequired[Callable[[str], list[str]]]
    
    1510
    +
    
    1511
    +
    
    1512
    +class CommandConfig(TypedDict):
    
    1513
    +    func: Callable[[argparse.Namespace], None]
    
    1514
    +    args: NotRequired[dict[str, ArgConfig]]
    
    1515
    +
    
    1516
    +
    
    1517
    +all_commands: dict[str, CommandConfig] = {
    
    703 1518
         "show-upstream-basis-commit": {
    
    704 1519
             "func": show_upstream_basis_commit,
    
    705 1520
         },
    
    ... ... @@ -716,8 +1531,8 @@ for name, details in {
    716 1531
                 },
    
    717 1532
             },
    
    718 1533
         },
    
    719
    -    "auto-fixup": {
    
    720
    -        "func": auto_fixup,
    
    1534
    +    "auto-commit": {
    
    1535
    +        "func": auto_commit,
    
    721 1536
         },
    
    722 1537
         "clean-fixups": {
    
    723 1538
             "func": clean_fixups,
    
    ... ... @@ -794,20 +1609,25 @@ for name, details in {
    794 1609
                 "regex": {"help": "the regex that the files must contain"},
    
    795 1610
             },
    
    796 1611
         },
    
    797
    -}.items():
    
    798
    -    help_message = re.sub(r"\s+", " ", details["func"].__doc__).strip()
    
    1612
    +}
    
    1613
    +
    
    1614
    +for name, command_config in all_commands.items():
    
    1615
    +    help_message = command_config["func"].__doc__
    
    1616
    +    assert isinstance(help_message, str)
    
    1617
    +    help_message = re.sub(r"\s+", " ", help_message).strip()
    
    799 1618
         sub = subparsers.add_parser(name, help=help_message)
    
    800
    -    sub.set_defaults(func=details["func"])
    
    801
    -    for arg, keywords in details.get("args", {}).items():
    
    1619
    +    sub.set_defaults(func=command_config["func"])
    
    1620
    +    for arg, keywords in command_config.get("args", {}).items():
    
    802 1621
             completer = None
    
    803 1622
             if "completer" in keywords:
    
    804 1623
                 completer = keywords["completer"]
    
    805 1624
                 del keywords["completer"]
    
    806 1625
             sub_arg = sub.add_argument(arg, **keywords)
    
    807
    -        if completer is not None:
    
    808
    -            sub_arg.completer = completer
    
    1626
    +        if completer is not None and argcomplete is not None:
    
    1627
    +            sub_arg.completer = completer  # type: ignore
    
    809 1628
     
    
    810
    -argcomplete.autocomplete(parser)
    
    1629
    +if argcomplete is not None:
    
    1630
    +    argcomplete.autocomplete(parser)
    
    811 1631
     
    
    812 1632
     try:
    
    813 1633
         if not within_browser_root():
    

  • _______________________________________________
    tor-commits mailing list -- tor-commits@xxxxxxxxxxxxxxxxxxxx
    To unsubscribe send an email to tor-commits-leave@xxxxxxxxxxxxxxxxxxxx