mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-04-20 11:18:22 +00:00
Implement command forgiveness with autocorrect
Similar to `git`, when a user types an unknown command like `repo tart`, we now use `difflib.get_close_matches` to suggest similar commands. If `help.autocorrect` is set in the git config, it will optionally prompt the user to automatically run the assumed command, or wait for a configured delay before executing it. Verification Steps: 1. Created a dummy repo project locally. 2. Verified `help.autocorrect=0|false|off|no|show` suggests command and exits. 3. Verified `help.autocorrect=1|true|on|yes|immediate` automatically runs suggestion. 4. Verified `help.autocorrect=<number>` runs after `<number>*0.1` seconds. 5. Verified `help.autocorrect=never` exits immediately without suggestions. 6. Verified `help.autocorrect=prompt` asks user to accept [y/n] and handles correctly. BUG: b/489753302 Change-Id: I6dcd63229cbd7badf5404459b48690c68f5b4857 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/558021 Tested-by: Sam Saccone <samccone@google.com> Commit-Queue: Sam Saccone <samccone@google.com> Reviewed-by: Mike Frysinger <vapier@google.com>
This commit is contained in:
126
main.py
126
main.py
@@ -19,6 +19,7 @@ People shouldn't run this directly; instead, they should use the `repo` wrapper
|
||||
which takes care of execing this entry point.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import getpass
|
||||
import json
|
||||
import netrc
|
||||
@@ -29,6 +30,7 @@ import signal
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from typing import Optional
|
||||
import urllib.request
|
||||
|
||||
from repo_logging import RepoLogger
|
||||
@@ -292,6 +294,102 @@ class _Repo:
|
||||
result = run()
|
||||
return result
|
||||
|
||||
def _autocorrect_command_name(
|
||||
self, name: str, config: RepoConfig
|
||||
) -> Optional[str]:
|
||||
"""Autocorrect command name based on user's git config."""
|
||||
|
||||
close_commands = difflib.get_close_matches(
|
||||
name, self.commands.keys(), n=5, cutoff=0.7
|
||||
)
|
||||
|
||||
if not close_commands:
|
||||
logger.error(
|
||||
"repo: '%s' is not a repo command. See 'repo help'.", name
|
||||
)
|
||||
return None
|
||||
|
||||
assumed = close_commands[0]
|
||||
autocorrect = config.GetString("help.autocorrect")
|
||||
|
||||
# If there are multiple close matches, git won't automatically run one.
|
||||
# We'll always prompt instead of guessing.
|
||||
if len(close_commands) > 1:
|
||||
autocorrect = "prompt"
|
||||
|
||||
# Handle git configuration boolean values:
|
||||
# 0, "false", "off", "no", "show": show suggestion (default)
|
||||
# 1, "true", "on", "yes", "immediate": run suggestion immediately
|
||||
# "never": don't run or show any suggested command
|
||||
# "prompt": show the suggestion and prompt for confirmation
|
||||
# positive number > 1: run suggestion after specified deciseconds
|
||||
if autocorrect is None:
|
||||
autocorrect = "0"
|
||||
|
||||
autocorrect = autocorrect.lower()
|
||||
|
||||
if autocorrect in ("0", "false", "off", "no", "show"):
|
||||
autocorrect = 0
|
||||
elif autocorrect in ("true", "on", "yes", "immediate"):
|
||||
autocorrect = -1 # immediate
|
||||
elif autocorrect == "never":
|
||||
return None
|
||||
elif autocorrect == "prompt":
|
||||
logger.warning(
|
||||
"You called a repo command named "
|
||||
"'%s', which does not exist.",
|
||||
name,
|
||||
)
|
||||
try:
|
||||
resp = input(f"Run '{assumed}' instead [y/N]? ")
|
||||
if resp.lower().startswith("y"):
|
||||
return assumed
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
pass
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
autocorrect = int(autocorrect)
|
||||
except ValueError:
|
||||
autocorrect = 0
|
||||
|
||||
if autocorrect != 0:
|
||||
if autocorrect < 0:
|
||||
logger.warning(
|
||||
"You called a repo command named "
|
||||
"'%s', which does not exist.\n"
|
||||
"Continuing assuming that "
|
||||
"you meant '%s'.",
|
||||
name,
|
||||
assumed,
|
||||
)
|
||||
else:
|
||||
delay = autocorrect * 0.1
|
||||
logger.warning(
|
||||
"You called a repo command named "
|
||||
"'%s', which does not exist.\n"
|
||||
"Continuing in %.1f seconds, assuming "
|
||||
"that you meant '%s'.",
|
||||
name,
|
||||
delay,
|
||||
assumed,
|
||||
)
|
||||
try:
|
||||
time.sleep(delay)
|
||||
except KeyboardInterrupt:
|
||||
return None
|
||||
return assumed
|
||||
|
||||
logger.error(
|
||||
"repo: '%s' is not a repo command. See 'repo help'.", name
|
||||
)
|
||||
logger.warning(
|
||||
"The most similar command%s\n\t%s",
|
||||
"s are" if len(close_commands) > 1 else " is",
|
||||
"\n\t".join(close_commands),
|
||||
)
|
||||
return None
|
||||
|
||||
def _RunLong(self, name, gopts, argv, git_trace2_event_log):
|
||||
"""Execute the (longer running) requested subcommand."""
|
||||
result = 0
|
||||
@@ -306,20 +404,22 @@ class _Repo:
|
||||
outer_client=outer_client,
|
||||
)
|
||||
|
||||
try:
|
||||
cmd = self.commands[name](
|
||||
repodir=self.repodir,
|
||||
client=repo_client,
|
||||
manifest=repo_client.manifest,
|
||||
outer_client=outer_client,
|
||||
outer_manifest=outer_client.manifest,
|
||||
git_event_log=git_trace2_event_log,
|
||||
if name not in self.commands:
|
||||
corrected_name = self._autocorrect_command_name(
|
||||
name, outer_client.globalConfig
|
||||
)
|
||||
except KeyError:
|
||||
logger.error(
|
||||
"repo: '%s' is not a repo command. See 'repo help'.", name
|
||||
)
|
||||
return 1
|
||||
if not corrected_name:
|
||||
return 1
|
||||
name = corrected_name
|
||||
|
||||
cmd = self.commands[name](
|
||||
repodir=self.repodir,
|
||||
client=repo_client,
|
||||
manifest=repo_client.manifest,
|
||||
outer_client=outer_client,
|
||||
outer_manifest=outer_client.manifest,
|
||||
git_event_log=git_trace2_event_log,
|
||||
)
|
||||
|
||||
Editor.globalConfig = cmd.client.globalConfig
|
||||
|
||||
|
||||
166
tests/test_main.py
Normal file
166
tests/test_main.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the main repo script and subcommand routing."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from main import _Repo
|
||||
|
||||
|
||||
@pytest.fixture(name="repo")
|
||||
def fixture_repo():
|
||||
repo = _Repo("repodir")
|
||||
# Overriding the command list here ensures that we are only testing
|
||||
# against a fixed set of commands, reducing fragility to new
|
||||
# subcommands being added to the main repo tool.
|
||||
repo.commands = {"start": None, "sync": None, "smart": None}
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_config")
|
||||
def fixture_mock_config():
|
||||
return mock.MagicMock()
|
||||
|
||||
|
||||
@mock.patch("time.sleep")
|
||||
def test_autocorrect_delay(mock_sleep, repo, mock_config):
|
||||
"""Test autocorrect with positive delay."""
|
||||
mock_config.GetString.return_value = "10"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
mock_config.GetString.assert_called_with("help.autocorrect")
|
||||
mock_sleep.assert_called_with(1.0)
|
||||
assert res == "start"
|
||||
|
||||
|
||||
@mock.patch("time.sleep")
|
||||
def test_autocorrect_delay_one(mock_sleep, repo, mock_config):
|
||||
"""Test autocorrect with '1' (0.1s delay, not immediate)."""
|
||||
mock_config.GetString.return_value = "1"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
mock_sleep.assert_called_with(0.1)
|
||||
assert res == "start"
|
||||
|
||||
|
||||
@mock.patch("time.sleep", side_effect=KeyboardInterrupt())
|
||||
def test_autocorrect_delay_interrupt(mock_sleep, repo, mock_config):
|
||||
"""Test autocorrect handles KeyboardInterrupt during delay."""
|
||||
mock_config.GetString.return_value = "10"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
mock_sleep.assert_called_with(1.0)
|
||||
assert res is None
|
||||
|
||||
|
||||
@mock.patch("time.sleep")
|
||||
def test_autocorrect_immediate(mock_sleep, repo, mock_config):
|
||||
"""Test autocorrect with immediate/negative delay."""
|
||||
# Test numeric negative.
|
||||
mock_config.GetString.return_value = "-1"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
mock_sleep.assert_not_called()
|
||||
assert res == "start"
|
||||
|
||||
# Test string boolean "true".
|
||||
mock_config.GetString.return_value = "true"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
mock_sleep.assert_not_called()
|
||||
assert res == "start"
|
||||
|
||||
# Test string boolean "yes".
|
||||
mock_config.GetString.return_value = "YES"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
mock_sleep.assert_not_called()
|
||||
assert res == "start"
|
||||
|
||||
# Test string boolean "immediate".
|
||||
mock_config.GetString.return_value = "Immediate"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
mock_sleep.assert_not_called()
|
||||
assert res == "start"
|
||||
|
||||
|
||||
def test_autocorrect_zero_or_show(repo, mock_config):
|
||||
"""Test autocorrect with zero delay (suggestions only)."""
|
||||
# Test numeric zero.
|
||||
mock_config.GetString.return_value = "0"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
assert res is None
|
||||
|
||||
# Test string boolean "false".
|
||||
mock_config.GetString.return_value = "False"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
assert res is None
|
||||
|
||||
# Test string boolean "show".
|
||||
mock_config.GetString.return_value = "show"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_autocorrect_never(repo, mock_config):
|
||||
"""Test autocorrect with 'never'."""
|
||||
mock_config.GetString.return_value = "never"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
assert res is None
|
||||
|
||||
|
||||
@mock.patch("builtins.input", return_value="y")
|
||||
def test_autocorrect_prompt_yes(mock_input, repo, mock_config):
|
||||
"""Test autocorrect with prompt and user answers yes."""
|
||||
mock_config.GetString.return_value = "prompt"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
assert res == "start"
|
||||
|
||||
|
||||
@mock.patch("builtins.input", return_value="n")
|
||||
def test_autocorrect_prompt_no(mock_input, repo, mock_config):
|
||||
"""Test autocorrect with prompt and user answers no."""
|
||||
mock_config.GetString.return_value = "prompt"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
assert res is None
|
||||
|
||||
|
||||
@mock.patch("builtins.input", return_value="y")
|
||||
def test_autocorrect_multiple_candidates(mock_input, repo, mock_config):
|
||||
"""Test autocorrect with multiple matches forces a prompt."""
|
||||
mock_config.GetString.return_value = "10" # Normally just delay
|
||||
|
||||
# 'snart' matches both 'start' and 'smart' with > 0.7 ratio
|
||||
res = repo._autocorrect_command_name("snart", mock_config)
|
||||
|
||||
# Because there are multiple candidates, it should prompt
|
||||
mock_input.assert_called_once()
|
||||
assert res == "start"
|
||||
|
||||
|
||||
@mock.patch("builtins.input", side_effect=KeyboardInterrupt())
|
||||
def test_autocorrect_prompt_interrupt(mock_input, repo, mock_config):
|
||||
"""Test autocorrect with prompt and user interrupts."""
|
||||
mock_config.GetString.return_value = "prompt"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
assert res is None
|
||||
Reference in New Issue
Block a user