From b5edeb0d796ca8b3423be924a9c0ed5254ab6413 Mon Sep 17 00:00:00 2001 From: Zhe Yu Date: Sat, 10 May 2025 12:04:52 +0800 Subject: [PATCH 1/3] refactor(cli): Merge hooks subcommand into init --- src/vectorcode/cli_utils.py | 7 +++ src/vectorcode/main.py | 4 ++ src/vectorcode/subcommands/hooks.py | 86 +------------------------ src/vectorcode/subcommands/init.py | 97 ++++++++++++++++++++++++++++- tests/subcommands/test_hooks.py | 65 +++++++++---------- tests/subcommands/test_init.py | 33 +++++++++- 6 files changed, 174 insertions(+), 118 deletions(-) diff --git a/src/vectorcode/cli_utils.py b/src/vectorcode/cli_utils.py index ed8f3a48..b1198890 100644 --- a/src/vectorcode/cli_utils.py +++ b/src/vectorcode/cli_utils.py @@ -96,6 +96,7 @@ class Config: hnsw: dict[str, str | int] = field(default_factory=dict) chunk_filters: dict[str, list[str]] = field(default_factory=dict) encoding: str = "utf8" + hooks: bool = False @classmethod async def import_from(cls, config_dict: dict[str, Any]) -> "Config": @@ -307,6 +308,12 @@ def get_cli_parser(): default=False, help="Wipe current project config and overwrite with global config (if it exists).", ) + init_parser.add_argument( + "--hooks", + action="store_true", + default=False, + help="Add git hooks to the current project, if it's a git repo.", + ) subparsers.add_parser( "version", parents=[shared_parser], help="Print the version number." diff --git a/src/vectorcode/main.py b/src/vectorcode/main.py index 3a12c3f0..b930f4f7 100644 --- a/src/vectorcode/main.py +++ b/src/vectorcode/main.py @@ -63,6 +63,10 @@ async def async_main(): return_val = await chunks(final_configs) case CliAction.hooks: + logger.warning( + "`vectorcode hooks` has been deprecated and will be removed in 0.7.0." + ) + logger.warning("Please use `vectorcode init --hooks`.") from vectorcode.subcommands import hooks return await hooks(cli_args) diff --git a/src/vectorcode/subcommands/hooks.py b/src/vectorcode/subcommands/hooks.py index 96e25ba3..df206860 100644 --- a/src/vectorcode/subcommands/hooks.py +++ b/src/vectorcode/subcommands/hooks.py @@ -1,92 +1,10 @@ -import glob import logging import os -import platform -import re -import stat -from pathlib import Path -from typing import Optional -from vectorcode.cli_utils import GLOBAL_CONFIG_PATH, Config, find_project_root +from vectorcode.cli_utils import Config, find_project_root +from vectorcode.subcommands.init import __HOOK_CONTENTS, HookFile, load_hooks logger = logging.getLogger(name=__name__) -__GLOBAL_HOOKS_PATH = Path(GLOBAL_CONFIG_PATH).parent / "hooks" - - -# Keys: name of the hooks, ie. `pre-commit` -# Values: lines of the hooks. -__HOOK_CONTENTS: dict[str, list[str]] = { - "pre-commit": [ - "diff_files=$(git diff --cached --name-only)", - '[ -z "$diff_files" ] || vectorcode vectorise $diff_files', - ], - "post-checkout": [ - 'files=$(git diff --name-only "$1" "$2")', - '[ -z "$files" ] || vectorcode vectorise $files', - ], -} - - -def __lines_are_empty(lines: list[str]) -> bool: - pattern = re.compile(r"^\s*$") - if len(lines) == 0: - return True - return all(map(lambda line: pattern.match(line) is not None, lines)) - - -def load_hooks(): - global __HOOK_CONTENTS - for file in glob.glob(str(__GLOBAL_HOOKS_PATH / "*")): - hook_name = Path(file).stem - with open(file) as fin: - lines = fin.readlines() - if not __lines_are_empty(lines): - __HOOK_CONTENTS[hook_name] = lines - - -class HookFile: - prefix = "# VECTORCODE_HOOK_START" - suffix = "# VECTORCODE_HOOK_END" - prefix_pattern = re.compile(r"^\s*#\s*VECTORCODE_HOOK_START\s*") - suffix_pattern = re.compile(r"^\s*#\s*VECTORCODE_HOOK_END\s*") - - def __init__(self, path: str | Path, git_dir: Optional[str | Path] = None): - self.path = path - self.lines: list[str] = [] - if os.path.isfile(self.path): - with open(self.path) as fin: - self.lines.extend(fin.readlines()) - - def has_vectorcode_hooks(self, force: bool = False) -> bool: - for start, start_line in enumerate(self.lines): - if self.prefix_pattern.match(start_line) is None: - continue - - for end in range(start + 1, len(self.lines)): - if self.suffix_pattern.match(self.lines[end]) is not None: - if force: - logger.debug("`force` cleaning existing VectorCode hooks...") - new_lines = self.lines[:start] + self.lines[end + 1 :] - self.lines[:] = new_lines - return False - logger.debug( - f"Found vectorcode hook block between line {start} and {end} in {self.path}:\n{''.join(self.lines[start + 1 : end])}" - ) - return True - - return False - - def inject_hook(self, content: list[str], force: bool = False): - if len(self.lines) == 0 or not self.has_vectorcode_hooks(force): - self.lines.append(self.prefix + "\n") - self.lines.extend(i if i.endswith("\n") else i + "\n" for i in content) - self.lines.append(self.suffix + "\n") - with open(self.path, "w") as fin: - fin.writelines(self.lines) - if platform.system() != "Windows": - # for unix systems, set the executable bit. - curr_mode = os.stat(self.path).st_mode - os.chmod(self.path, mode=curr_mode | stat.S_IXUSR) async def hooks(configs: Config) -> int: diff --git a/src/vectorcode/subcommands/init.py b/src/vectorcode/subcommands/init.py index 316d395b..15ddc13c 100644 --- a/src/vectorcode/subcommands/init.py +++ b/src/vectorcode/subcommands/init.py @@ -1,13 +1,98 @@ +import glob import logging import os +import platform +import re import shutil +import stat +from pathlib import Path +from typing import Optional -from vectorcode.cli_utils import Config +from vectorcode.cli_utils import GLOBAL_CONFIG_PATH, Config, find_project_root logger = logging.getLogger(name=__name__) +__GLOBAL_HOOKS_PATH = Path(GLOBAL_CONFIG_PATH).parent / "hooks" + + +# Keys: name of the hooks, ie. `pre-commit` +# Values: lines of the hooks. +__HOOK_CONTENTS: dict[str, list[str]] = { + "pre-commit": [ + "diff_files=$(git diff --cached --name-only)", + '[ -z "$diff_files" ] || vectorcode vectorise $diff_files', + ], + "post-checkout": [ + 'files=$(git diff --name-only "$1" "$2")', + '[ -z "$files" ] || vectorcode vectorise $files', + ], +} + + +def __lines_are_empty(lines: list[str]) -> bool: + pattern = re.compile(r"^\s*$") + if len(lines) == 0: + return True + return all(map(lambda line: pattern.match(line) is not None, lines)) + + +def load_hooks(): + global __HOOK_CONTENTS + for file in glob.glob(str(__GLOBAL_HOOKS_PATH / "*")): + hook_name = Path(file).stem + with open(file) as fin: + lines = fin.readlines() + if not __lines_are_empty(lines): + __HOOK_CONTENTS[hook_name] = lines + + +class HookFile: + prefix = "# VECTORCODE_HOOK_START" + suffix = "# VECTORCODE_HOOK_END" + prefix_pattern = re.compile(r"^\s*#\s*VECTORCODE_HOOK_START\s*") + suffix_pattern = re.compile(r"^\s*#\s*VECTORCODE_HOOK_END\s*") + + def __init__(self, path: str | Path, git_dir: Optional[str | Path] = None): + self.path = path + self.lines: list[str] = [] + if os.path.isfile(self.path): + with open(self.path) as fin: + self.lines.extend(fin.readlines()) + + def has_vectorcode_hooks(self, force: bool = False) -> bool: + for start, start_line in enumerate(self.lines): + if self.prefix_pattern.match(start_line) is None: + continue + + for end in range(start + 1, len(self.lines)): + if self.suffix_pattern.match(self.lines[end]) is not None: + if force: + logger.debug("`force` cleaning existing VectorCode hooks...") + new_lines = self.lines[:start] + self.lines[end + 1 :] + self.lines[:] = new_lines + return False + logger.debug( + f"Found vectorcode hook block between line {start} and {end} in {self.path}:\n{''.join(self.lines[start + 1 : end])}" + ) + return True + + return False + + def inject_hook(self, content: list[str], force: bool = False): + if len(self.lines) == 0 or not self.has_vectorcode_hooks(force): + self.lines.append(self.prefix + "\n") + self.lines.extend(i if i.endswith("\n") else i + "\n" for i in content) + self.lines.append(self.suffix + "\n") + with open(self.path, "w") as fin: + fin.writelines(self.lines) + if platform.system() != "Windows": + # for unix systems, set the executable bit. + curr_mode = os.stat(self.path).st_mode + os.chmod(self.path, mode=curr_mode | stat.S_IXUSR) + async def init(configs: Config) -> int: + assert configs.project_root is not None project_config_dir = os.path.join(str(configs.project_root), ".vectorcode") if os.path.isdir(project_config_dir) and not configs.force: logger.warning( @@ -25,6 +110,16 @@ async def init(configs: Config) -> int: logger.debug(f"Copying global {item} to {project_config_dir}") shutil.copyfile(global_file_path, local_file_path) + git_root = find_project_root(configs.project_root, ".git") + if git_root: + load_hooks() + for hook in __HOOK_CONTENTS.keys(): + hook_file_path = os.path.join(git_root, ".git", "hooks", hook) + logger.info(f"Writing {hook} hook into {hook_file_path}.") + print(f"Processing {hook} hook...") + hook_obj = HookFile(hook_file_path, git_dir=git_root) + hook_obj.inject_hook(__HOOK_CONTENTS[hook], configs.force) + print(f"VectorCode project root has been initialised at {configs.project_root}") print( "Note: The collection in the database will not be created until you vectorise a file." diff --git a/tests/subcommands/test_hooks.py b/tests/subcommands/test_hooks.py index cc150bf7..27aa1526 100644 --- a/tests/subcommands/test_hooks.py +++ b/tests/subcommands/test_hooks.py @@ -5,7 +5,8 @@ import pytest from vectorcode.cli_utils import Config -from vectorcode.subcommands.hooks import HookFile, __lines_are_empty, hooks, load_hooks +from vectorcode.subcommands.hooks import hooks +from vectorcode.subcommands.init import HookFile, __lines_are_empty, load_hooks @pytest.fixture(scope="function") @@ -15,7 +16,7 @@ def mock_hook_path() -> Path: @pytest.fixture(autouse=True, scope="function") def reset_hook_contents(): - from vectorcode.subcommands.hooks import __GLOBAL_HOOKS_PATH, __HOOK_CONTENTS + from vectorcode.subcommands.init import __GLOBAL_HOOKS_PATH, __HOOK_CONTENTS original_hooks_path = __GLOBAL_HOOKS_PATH original_contents = __HOOK_CONTENTS.copy() @@ -35,10 +36,10 @@ def test_lines_are_empty(): assert not __lines_are_empty([" hello ", "\tworld"]) -@patch("vectorcode.subcommands.hooks.glob") +@patch("vectorcode.subcommands.init.glob") @patch("vectorcode.subcommands.open", new_callable=mock_open) def test_load_hooks_no_files(mock_open_func, mock_glob): - from vectorcode.subcommands.hooks import __GLOBAL_HOOKS_PATH + from vectorcode.subcommands.init import __GLOBAL_HOOKS_PATH mock_glob.glob.return_value = [] expected_glob_path = str(__GLOBAL_HOOKS_PATH / "*") @@ -49,15 +50,15 @@ def test_load_hooks_no_files(mock_open_func, mock_glob): mock_open_func.assert_not_called() -@patch("vectorcode.subcommands.hooks.glob") +@patch("vectorcode.subcommands.init.glob") @patch( - "vectorcode.subcommands.hooks.open", + "vectorcode.subcommands.init.open", new_callable=mock_open, read_data="Hook line 1\nLine 2", ) def test_load_hooks_one_file(mock_open_func, mock_glob): """Test load_hooks with a single valid hook file.""" - from vectorcode.subcommands.hooks import __GLOBAL_HOOKS_PATH, __HOOK_CONTENTS + from vectorcode.subcommands.init import __GLOBAL_HOOKS_PATH, __HOOK_CONTENTS hook_file_path = str(__GLOBAL_HOOKS_PATH / "test-hook") mock_glob.glob.return_value = [hook_file_path] @@ -71,10 +72,10 @@ def test_load_hooks_one_file(mock_open_func, mock_glob): mock_open_func.assert_called_once_with(hook_file_path) -@patch("vectorcode.subcommands.hooks.glob") -@patch("vectorcode.subcommands.hooks.open", new_callable=mock_open) +@patch("vectorcode.subcommands.init.glob") +@patch("vectorcode.subcommands.init.open", new_callable=mock_open) def test_load_hooks_multiple_files(mock_open_func, mock_glob): - from vectorcode.subcommands.hooks import __GLOBAL_HOOKS_PATH, __HOOK_CONTENTS + from vectorcode.subcommands.init import __GLOBAL_HOOKS_PATH, __HOOK_CONTENTS """Test load_hooks with multiple hook files.""" @@ -101,10 +102,10 @@ def test_load_hooks_multiple_files(mock_open_func, mock_glob): mock_open_func.assert_any_call(hook_file_path2) -@patch("vectorcode.subcommands.hooks.glob") -@patch("vectorcode.subcommands.hooks.open", new_callable=mock_open, read_data="") +@patch("vectorcode.subcommands.init.glob") +@patch("vectorcode.subcommands.init.open", new_callable=mock_open, read_data="") def test_load_hooks_empty_file(mock_open_func, mock_glob): - from vectorcode.subcommands.hooks import __GLOBAL_HOOKS_PATH, __HOOK_CONTENTS + from vectorcode.subcommands.init import __GLOBAL_HOOKS_PATH, __HOOK_CONTENTS """Test load_hooks with an empty hook file.""" @@ -119,13 +120,13 @@ def test_load_hooks_empty_file(mock_open_func, mock_glob): mock_open_func.assert_called_once_with(hook_file_path) -@patch("vectorcode.subcommands.hooks.glob") +@patch("vectorcode.subcommands.init.glob") @patch( - "vectorcode.subcommands.hooks.open", new_callable=mock_open, read_data="\n \n\t\n" + "vectorcode.subcommands.init.open", new_callable=mock_open, read_data="\n \n\t\n" ) def test_load_hooks_whitespace_file(mock_open_func, mock_glob): """Test load_hooks with a hook file containing only whitespace.""" - from vectorcode.subcommands.hooks import __GLOBAL_HOOKS_PATH, __HOOK_CONTENTS + from vectorcode.subcommands.init import __GLOBAL_HOOKS_PATH, __HOOK_CONTENTS hook_file_path = str(__GLOBAL_HOOKS_PATH / "whitespace-hook") mock_glob.glob.return_value = [hook_file_path] @@ -140,7 +141,7 @@ def test_load_hooks_whitespace_file(mock_open_func, mock_glob): @patch("vectorcode.subcommands.hooks.os.path.isfile") @patch( - "vectorcode.subcommands.hooks.open", + "vectorcode.subcommands.init.open", new_callable=mock_open, read_data="Existing line 1\nExisting line 2", ) @@ -157,7 +158,7 @@ def test_hookfile_init_existing_file(mock_open_func, mock_isfile, mock_hook_path @patch("vectorcode.subcommands.hooks.os.path.isfile") -@patch("vectorcode.subcommands.hooks.open", new_callable=mock_open) +@patch("vectorcode.subcommands.init.open", new_callable=mock_open) def test_hookfile_init_non_existent_file(mock_open_func, mock_isfile, mock_hook_path): """Test HookFile initialization when the hook file does not exist.""" mock_isfile.return_value = False @@ -217,7 +218,7 @@ def test_hookfile_init_non_existent_file(mock_open_func, mock_isfile, mock_hook_ ], ) @patch("vectorcode.subcommands.hooks.os.path.isfile", return_value=True) -@patch("vectorcode.subcommands.hooks.open", new_callable=mock_open) +@patch("vectorcode.subcommands.init.open", new_callable=mock_open) def test_hookfile_has_vectorcode_hooks( mock_open_func, mock_isfile, lines, expected, mock_hook_path ): @@ -229,11 +230,11 @@ def test_hookfile_has_vectorcode_hooks( assert hook_file.has_vectorcode_hooks() == expected -@patch("vectorcode.subcommands.hooks.platform.system") +@patch("vectorcode.subcommands.init.platform.system") @patch("vectorcode.subcommands.hooks.os.chmod") @patch("vectorcode.subcommands.hooks.os.stat") @patch("vectorcode.subcommands.hooks.os.path.isfile") -@patch("vectorcode.subcommands.hooks.open", new_callable=mock_open) +@patch("vectorcode.subcommands.init.open", new_callable=mock_open) def test_hookfile_inject_hook_new_file( mock_open_func, mock_isfile, mock_stat, mock_chmod, mock_platform, mock_hook_path ): @@ -264,12 +265,12 @@ def test_hookfile_inject_hook_new_file( mock_chmod.assert_called_once_with(mock_hook_path, mode=expected_mode) -@patch("vectorcode.subcommands.hooks.platform.system") +@patch("vectorcode.subcommands.init.platform.system") @patch("vectorcode.subcommands.hooks.os.chmod") @patch("vectorcode.subcommands.hooks.os.stat") @patch("vectorcode.subcommands.hooks.os.path.isfile") @patch( - "vectorcode.subcommands.hooks.open", + "vectorcode.subcommands.init.open", new_callable=mock_open, read_data="Existing line 1\n", ) @@ -310,11 +311,11 @@ def test_hookfile_inject_hook_existing_file_no_vc_hooks( mock_chmod.assert_not_called() -@patch("vectorcode.subcommands.hooks.platform.system") +@patch("vectorcode.subcommands.init.platform.system") @patch("vectorcode.subcommands.hooks.os.chmod") @patch("vectorcode.subcommands.hooks.os.stat") @patch("vectorcode.subcommands.hooks.os.path.isfile") -@patch("vectorcode.subcommands.hooks.open", new_callable=mock_open) +@patch("vectorcode.subcommands.init.open", new_callable=mock_open) def test_hookfile_inject_hook_existing_file_with_vc_hooks( mock_open_func, mock_isfile, mock_stat, mock_chmod, mock_platform, mock_hook_path ): @@ -367,7 +368,7 @@ def test_hookfile_inject_hook_existing_file_with_vc_hooks( @pytest.mark.asyncio @patch("vectorcode.subcommands.hooks.find_project_root", return_value=None) -@patch("vectorcode.subcommands.hooks.load_hooks") +@patch("vectorcode.subcommands.init.load_hooks") async def test_hooks_orchestration_no_git_repo(mock_load_hooks, mock_find_project_root): """Test hooks orchestration: handles no git repo found.""" mock_config = Config(project_root="/some/path") @@ -387,7 +388,7 @@ async def test_hooks_orchestration_default_hooks( mock_HookFile, mock_load_hooks, mock_find_project_root ): """Test hooks orchestration: handles git repo found but no hooks loaded.""" - from vectorcode.subcommands.hooks import __HOOK_CONTENTS + from vectorcode.subcommands.init import __HOOK_CONTENTS __HOOK_CONTENTS.clear() __HOOK_CONTENTS.update( @@ -453,7 +454,7 @@ async def test_hooks_orchestration_with_hooks( mock_HookFile.return_value = mock_hook_instance with patch.dict( - "vectorcode.subcommands.hooks.__HOOK_CONTENTS", defined_hooks, clear=True + "vectorcode.subcommands.init.__HOOK_CONTENTS", defined_hooks, clear=True ): return_code = await hooks(mock_config) @@ -475,7 +476,7 @@ async def test_hooks_orchestration_with_hooks( @patch("vectorcode.subcommands.hooks.os.path.isfile", return_value=True) @patch( - "vectorcode.subcommands.hooks.open", + "vectorcode.subcommands.init.open", new_callable=mock_open, ) def test_hookfile_has_vectorcode_hooks_force_removes_block( @@ -509,11 +510,11 @@ def test_hookfile_has_vectorcode_hooks_force_removes_block( assert hook_file.lines == expected_lines_after # Check if block was removed -@patch("vectorcode.subcommands.hooks.platform.system") +@patch("vectorcode.subcommands.init.platform.system") @patch("vectorcode.subcommands.hooks.os.chmod") @patch("vectorcode.subcommands.hooks.os.stat") @patch("vectorcode.subcommands.hooks.os.path.isfile") -@patch("vectorcode.subcommands.hooks.open", new_callable=mock_open) +@patch("vectorcode.subcommands.init.open", new_callable=mock_open) def test_hookfile_inject_hook_force_overwrites_existing( mock_open_func, mock_isfile, mock_stat, mock_chmod, mock_platform, mock_hook_path ): @@ -582,7 +583,7 @@ async def test_hooks_orchestration_force_true( mock_HookFile, mock_load_hooks, mock_find_project_root ): """Test hooks orchestration passes force=True to HookFile.inject_hook.""" - from vectorcode.subcommands.hooks import __HOOK_CONTENTS + from vectorcode.subcommands.init import __HOOK_CONTENTS # Ensure there's some hook content defined for the test defined_hooks = {"pre-commit": ["echo pre-commit"]} diff --git a/tests/subcommands/test_init.py b/tests/subcommands/test_init.py index 5d929bee..a71622d4 100644 --- a/tests/subcommands/test_init.py +++ b/tests/subcommands/test_init.py @@ -1,6 +1,6 @@ import os import tempfile -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -93,3 +93,34 @@ async def test_init_copies_global_config(capsys): f"VectorCode project root has been initialised at {temp_dir}" in captured.out ) + + +@pytest.mark.asyncio +async def test_hooks_orchestration_with_hooks(): + """Test hooks orchestration: handles git repo and loaded hooks.""" + + with tempfile.TemporaryDirectory() as temp_dir: + mock_config = Config(project_root=os.path.join(temp_dir, "project"), hooks=True) + defined_hooks = { + "pre-commit": ["line1"], + "post-commit": ["lineA", "lineB"], + } + + mock_hook_instance = MagicMock() + + with ( + patch.dict( + "vectorcode.subcommands.init.__HOOK_CONTENTS", defined_hooks, clear=True + ), + patch("vectorcode.subcommands.init.HookFile") as mock_HookFile, + patch( + "vectorcode.subcommands.init.find_project_root", + return_value=os.path.join(temp_dir, "project"), + ), + patch("vectorcode.subcommands.init.load_hooks") as mock_load_hooks, + ): + mock_HookFile.return_value = mock_hook_instance + return_code = await init(mock_config) + + mock_load_hooks.assert_called_once() + assert return_code == 0 From 5d851c99f445db0fe7d7344eb201cafc07953999 Mon Sep 17 00:00:00 2001 From: Zhe Yu Date: Sat, 10 May 2025 13:36:56 +0800 Subject: [PATCH 2/3] docs(cli): update documentation for hooks. --- docs/cli.md | 67 +++++++++++++++++++++++++++-------------------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 607f572f..340be973 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -13,10 +13,10 @@ * [If Anything Goes Wrong...](#if-anything-goes-wrong) * [Advanced Usage](#advanced-usage) * [Initialising a Project](#initialising-a-project) + * [Git Hooks](#git-hooks) * [Configuring VectorCode](#configuring-vectorcode) * [Vectorising Your Code](#vectorising-your-code) * [File Specs](#file-specs) - * [Git Hooks](#git-hooks) * [Making a Query](#making-a-query) * [Listing All Collections](#listing-all-collections) * [Removing a Collection](#removing-a-collection) @@ -123,8 +123,7 @@ vectorcode vectorise src/**/*.py ``` > VectorCode doesn't track file changes, so you need to re-vectorise edited > files. You may automate this by a git pre-commit hook, etc. See the -> [wiki](https://github.com/Davidyz/VectorCode/wiki/Tips-and-Tricks#git-hooks) -> for examples to set them up. +> [advanced usage section](#git-hooks) for examples to set them up. Ideally, you should try to vectorise all source code in the repo, but for large repos you may experience slow queries. If that happens, try to `vectorcode drop` @@ -164,7 +163,7 @@ to refresh the embedding for a particular file, and the CLI provides a are currently indexed by VectorCode for the current project. If you want something more automagic, check out -[this section in the wiki](https://github.com/Davidyz/VectorCode/wiki/Tips-and-Tricks#git-hooks) +[the advanced usage section](#git-hooks) about setting up git hooks to trigger automatic embedding updates when you commit/checkout to a different tag. @@ -205,6 +204,37 @@ contains `.git/` subdirectory and use it as the _project root_. In this case, th default global configuration will be used. If `.git/` does not exist, VectorCode falls back to using the current working directory as the _project root_. +#### Git Hooks + +To keep the embeddings up-to-date, you may find it useful to set up some git +hooks. The `init` subcommand provides a `--hooks` flag which helps you manage +hooks when working with a git repository. You can put some custom hooks in +`~/.config/vectorcode/hooks/` and the `vectorcode init --hooks` command will +pick them up and append them to your existing hooks, or create new hook scripts +if they don't exist yet. The hook files should be named the same as they would +be under the `.git/hooks` directory. For example, a pre-commit hook would be named +`~/.config/vectorcode/hooks/pre-commit`. + +By default, there are 2 pre-defined hooks: +```bash +# pre-commit hook that vectorise changed files before you commit. +diff_files=$(git diff --cached --name-only) +[ -z "$diff_files" ] || vectorcode vectorise $diff_files +``` +```bash +# post-checkout hook that vectorise changed files when you checkout to a +# different branch/tag/commit +files=$(git diff --name-only "$1" "$2") +[ -z "$files" ] || vectorcode vectorise $files +``` +When you run `vectorcode init --hooks` in a git repo, these 2 hooks will be added +to your `.git/hooks/`. Hooks that are managed by VectorCode will be wrapped by +`# VECTORCODE_HOOK_START` and `# VECTORCODE_HOOK_END` comment lines. They help +VectorCode determine whether hooks have been added, so don't delete the markers +unless you know what you're doing. To remove the hooks, simply delete the lines +wrapped by these 2 comment strings. + + ### Configuring VectorCode Since 0.6.4, VectorCode adapted a [json5 parser](https://github.com/dpranke/pyjson5) for loading configuration. VectorCode will now look for `config.json5` in @@ -366,35 +396,6 @@ on certain conditions. See [the wiki](https://github.com/Davidyz/VectorCode/wiki/Tips-and-Tricks#git-hooks) for an example to use it with git hooks. -#### Git Hooks - -To keep the embeddings up-to-date, you may find it useful to set up some git -hooks. The CLI provides a subcommand, `vectorcode hooks`, that helps you manage -hooks when working with a git repository. You can put some custom hooks in -`~/.config/vectorcode/hooks/` and the `vectorcode hooks` command will pick them -up and append them to your existing hooks, or create new hook scripts if they -don't exist yet. The hook files should be named the same as they would be under -the `.git/hooks` directory. For example, a pre-commit hook would be named -`~/.config/vectorcode/hooks/pre-commit`. By default, there are 2 pre-defined -hooks: -```bash -# pre-commit hook that vectorise changed files before you commit. -diff_files=$(git diff --cached --name-only) -[ -z "$diff_files" ] || vectorcode vectorise $diff_files -``` -```bash -# post-checkout hook that vectorise changed files when you checkout to a -# different branch/tag/commit -files=$(git diff --name-only "$1" "$2") -[ -z "$files" ] || vectorcode vectorise $files -``` -When you run `vectorcode hooks` in a git repo, these 2 hooks will be added to -your `.git/hooks/`. Hooks that are managed by VectorCode will be wrapped by -`# VECTORCODE_HOOK_START` and `# VECTORCODE_HOOK_END` comment lines. They help -VectorCode determine whether hooks have been added, so don't delete the markers -unless you know what you're doing. To remove the hooks, simply delete the lines -wrapped by these 2 comment strings. - ### Making a Query To retrieve a list of documents from the database, you can use the following command: From 561a4c8ffb6b3a5c78ac02e5bbadb5dcd75745e4 Mon Sep 17 00:00:00 2001 From: Zhe Yu Date: Sat, 10 May 2025 13:44:52 +0800 Subject: [PATCH 3/3] fix(cli): allow injecting hooks on a initialised project. --- src/vectorcode/subcommands/init.py | 32 ++++++++++++++++-------------- tests/subcommands/test_init.py | 2 +- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/vectorcode/subcommands/init.py b/src/vectorcode/subcommands/init.py index 15ddc13c..a1b61067 100644 --- a/src/vectorcode/subcommands/init.py +++ b/src/vectorcode/subcommands/init.py @@ -94,21 +94,27 @@ def inject_hook(self, content: list[str], force: bool = False): async def init(configs: Config) -> int: assert configs.project_root is not None project_config_dir = os.path.join(str(configs.project_root), ".vectorcode") + is_initialised = 0 if os.path.isdir(project_config_dir) and not configs.force: logger.warning( f"{configs.project_root} is already initialised for VectorCode.", ) - return 1 - - os.makedirs(project_config_dir, exist_ok=True) - for item in ("config.json", "vectorcode.include", "vectorcode.exclude"): - local_file_path = os.path.join(project_config_dir, item) - global_file_path = os.path.join( - os.path.expanduser("~"), ".config", "vectorcode", item + is_initialised = 1 + else: + os.makedirs(project_config_dir, exist_ok=True) + for item in ("config.json", "vectorcode.include", "vectorcode.exclude"): + local_file_path = os.path.join(project_config_dir, item) + global_file_path = os.path.join( + os.path.expanduser("~"), ".config", "vectorcode", item + ) + if os.path.isfile(global_file_path): + logger.debug(f"Copying global {item} to {project_config_dir}") + shutil.copyfile(global_file_path, local_file_path) + + print(f"VectorCode project root has been initialised at {configs.project_root}") + print( + "Note: The collection in the database will not be created until you vectorise a file." ) - if os.path.isfile(global_file_path): - logger.debug(f"Copying global {item} to {project_config_dir}") - shutil.copyfile(global_file_path, local_file_path) git_root = find_project_root(configs.project_root, ".git") if git_root: @@ -120,8 +126,4 @@ async def init(configs: Config) -> int: hook_obj = HookFile(hook_file_path, git_dir=git_root) hook_obj.inject_hook(__HOOK_CONTENTS[hook], configs.force) - print(f"VectorCode project root has been initialised at {configs.project_root}") - print( - "Note: The collection in the database will not be created until you vectorise a file." - ) - return 0 + return is_initialised diff --git a/tests/subcommands/test_init.py b/tests/subcommands/test_init.py index a71622d4..8256d0ea 100644 --- a/tests/subcommands/test_init.py +++ b/tests/subcommands/test_init.py @@ -31,7 +31,7 @@ async def test_init_already_initialized(capsys): # Try to initialize again without force return_code = await init(configs) - assert return_code == 1 + assert return_code != 0 @pytest.mark.asyncio