diff --git a/docs/source/components/configuration.rst b/docs/source/components/configuration.rst index 99fe8fa8..fff32dbc 100644 --- a/docs/source/components/configuration.rst +++ b/docs/source/components/configuration.rst @@ -361,6 +361,8 @@ Configures how **Sphinx-CodeLinks** analyse source files to extract markers from get_need_id_refs = true get_oneline_needs = true get_rst = true + # Optional: Explicit Git root for Bazel or deeply nested configs + # git_root = "/path/to/repo" [codelinks.projects.my_project.analyse.oneline_comment_style] start_sequence = "@" @@ -420,6 +422,29 @@ Enables the extraction of marked RST text from source code comments. When enable [codelinks.projects.my_project.analyse] get_rst = false +.. _`git_root`: + +git_root +^^^^^^^^ + +Specifies an explicit path to the Git repository root directory. This option is particularly useful in environments where the standard Git root auto-detection fails, such as: + +- **Bazel builds**: Where the execution path differs from the standard repository layout +- **Deeply nested configurations**: Where ``conf.py`` is located in a deep subdirectory far from the repository root +- **Custom build systems**: Where the working directory is different from the source repository + +When not set, **Sphinx-CodeLinks** will automatically traverse parent directories to locate the ``.git`` folder. + +**Type:** ``str`` (path) +**Default:** Not set (auto-detection) + +.. code-block:: toml + + [codelinks.projects.my_project.analyse] + git_root = "/absolute/path/to/repo" + +.. note:: When ``git_root`` is explicitly set, **Sphinx-CodeLinks** will use this path directly without attempting auto-detection. Ensure the path points to a valid Git repository containing a ``.git`` directory. + .. _`oneline_comment_style`: analyse.oneline_comment_style diff --git a/src/sphinx_codelinks/analyse/analyse.py b/src/sphinx_codelinks/analyse/analyse.py index 50b7b847..842c51b8 100644 --- a/src/sphinx_codelinks/analyse/analyse.py +++ b/src/sphinx_codelinks/analyse/analyse.py @@ -65,7 +65,11 @@ def __init__( self.oneline_needs: list[OneLineNeed] = [] self.marked_rst: list[MarkedRst] = [] self.all_marked_content: list[NeedIdRefs | OneLineNeed | MarkedRst] = [] - self.git_root: Path | None = utils.locate_git_root(self.analyse_config.src_dir) + # Use explicitly configured git_root if provided, otherwise auto-detect + if self.analyse_config.git_root is not None: + self.git_root: Path | None = self.analyse_config.git_root.resolve() + else: + self.git_root = utils.locate_git_root(self.analyse_config.src_dir) self.git_remote_url: str | None = ( utils.get_remote_url(self.git_root) if self.git_root else None ) diff --git a/src/sphinx_codelinks/cmd.py b/src/sphinx_codelinks/cmd.py index e341615b..6020509b 100644 --- a/src/sphinx_codelinks/cmd.py +++ b/src/sphinx_codelinks/cmd.py @@ -56,7 +56,7 @@ @app.command(no_args_is_help=True) -def analyse( # noqa: PLR0912 +def analyse( # noqa: PLR0912 # for CLI, so it needs the branches config: Annotated[ Path, typer.Argument( @@ -141,6 +141,12 @@ def analyse( # noqa: PLR0912 analyse_config.src_files = src_discover.source_paths analyse_config.src_dir = Path(src_discover.src_discover_config.src_dir) + # git_root shall be relative to the config file's location (like src_dir) + if analyse_config.git_root is not None: + analyse_config.git_root = ( + config.parent / analyse_config.git_root + ).resolve() + analyse_errors = analyse_config.check_fields_configuration() errors.extend(analyse_errors) if errors: diff --git a/src/sphinx_codelinks/config.py b/src/sphinx_codelinks/config.py index 3aac458b..c9f0b94a 100644 --- a/src/sphinx_codelinks/config.py +++ b/src/sphinx_codelinks/config.py @@ -305,6 +305,7 @@ class AnalyseSectionConfigType(TypedDict, total=False): get_oneline_needs: bool get_rst: bool outdir: str + git_root: str need_id_refs: NeedIdRefsConfigType marked_rst: MarkedRstConfigType oneline_comment_style: OneLineCommentStyleType @@ -319,6 +320,7 @@ class SourceAnalyseConfigType(TypedDict, total=False): get_need_id_refs: bool get_oneline_needs: bool get_rst: bool + git_root: Path | None need_id_refs_config: NeedIdRefsConfig marked_rst_config: MarkedRstConfig oneline_comment_style: OneLineCommentStyle @@ -361,6 +363,12 @@ def field_names(cls) -> set[str]: get_rst: bool = field(default=False, metadata={"schema": {"type": "boolean"}}) """Whether to extract rst texts from comments""" + git_root: Path | None = field( + default=None, metadata={"schema": {"type": ["string", "null"]}} + ) + """Explicit path to the Git repository root. If not set, it will be auto-detected + by traversing parent directories. Useful for Bazel builds or deeply nested configs.""" + need_id_refs_config: NeedIdRefsConfig = field(default_factory=NeedIdRefsConfig) """Configuration for extracting need id references from comments.""" @@ -765,9 +773,11 @@ def convert_analyse_config( if config_dict: for k, v in config_dict.items(): if k not in {"online_comment_style", "need_id_refs", "marked_rst"}: - analyse_config_dict[k] = ( # type: ignore[literal-required] # dynamical assignment - Path(v) if k == "src_dic" and isinstance(v, str) else v - ) + # Convert string paths to Path objects + if k in {"src_dir", "git_root"} and isinstance(v, str): + analyse_config_dict[k] = Path(v) # type: ignore[literal-required] + else: + analyse_config_dict[k] = v # type: ignore[literal-required] # dynamical assignment # Get oneline_comment_style configuration oneline_comment_style_dict: OneLineCommentStyleType | None = config_dict.get( diff --git a/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py b/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py index 3e46e51b..d7965e59 100644 --- a/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py +++ b/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py @@ -116,6 +116,13 @@ def run(self) -> list[nodes.Node]: analyse_config = src_trace_conf["analyse_config"] analyse_config.src_dir = src_dir analyse_config.src_files = source_files + # git_root shall be relative to the config file's location (if provided) + if analyse_config.git_root: + conf_dir = Path(self.env.app.confdir) + if src_trace_sphinx_config.config_from_toml: + src_trace_toml_path = Path(src_trace_sphinx_config.config_from_toml) + conf_dir = conf_dir / src_trace_toml_path.parent + analyse_config.git_root = (conf_dir / analyse_config.git_root).resolve() src_analyse = SourceAnalyse(analyse_config) src_analyse.run() diff --git a/tests/test_analyse.py b/tests/test_analyse.py index ab8dc162..6e6c2a7f 100644 --- a/tests/test_analyse.py +++ b/tests/test_analyse.py @@ -143,11 +143,79 @@ def test_analyse_oneline_needs( assert cnt_comments == result["num_comments"] +def test_explicit_git_root_configuration(tmp_path): + """Test that explicit git_root configuration is used instead of auto-detection.""" + # Create a fake git repo structure in tmp_path + fake_git_root = tmp_path / "fake_repo" + fake_git_root.mkdir() + (fake_git_root / ".git").mkdir() + + # Create a minimal .git/config with remote URL + git_config = fake_git_root / ".git" / "config" + git_config.write_text( + '[remote "origin"]\n url = https://github.com/test/repo.git\n' + ) + + # Create HEAD file pointing to a branch ref + git_head = fake_git_root / ".git" / "HEAD" + git_head.write_text("ref: refs/heads/main\n") + + # Create the refs/heads/main file with the commit hash + refs_dir = fake_git_root / ".git" / "refs" / "heads" + refs_dir.mkdir(parents=True) + (refs_dir / "main").write_text("abc123def456\n") + + # Create source file in a deeply nested location + src_dir = tmp_path / "deeply" / "nested" / "src" + src_dir.mkdir(parents=True) + src_file = src_dir / "test.c" + src_file.write_text("// @Test, TEST_1\nvoid test() {}\n") + + # Configure with explicit git_root + src_analyse_config = SourceAnalyseConfig( + src_files=[src_file], + src_dir=src_dir, + get_need_id_refs=False, + get_oneline_needs=True, + get_rst=False, + git_root=fake_git_root, + ) + + src_analyse = SourceAnalyse(src_analyse_config) + + # Verify the explicit git_root was used + assert src_analyse.git_root == fake_git_root.resolve() + assert src_analyse.git_remote_url == "https://github.com/test/repo.git" + assert src_analyse.git_commit_rev == "abc123def456" + + +def test_git_root_auto_detection_when_not_configured(tmp_path): + """Test that git_root is auto-detected when not explicitly configured.""" + src_dir = TEST_DIR / "data" / "dcdc" + src_paths = [src_dir / "charge" / "demo_1.cpp"] + + # Don't set git_root - it should auto-detect + src_analyse_config = SourceAnalyseConfig( + src_files=src_paths, + src_dir=src_dir, + get_need_id_refs=False, + get_oneline_needs=True, + get_rst=False, + # git_root is not set, so auto-detection should be used + ) + + src_analyse = SourceAnalyse(src_analyse_config) + + # The test is running inside a git repo, so git_root should be detected + # We just verify it's not None (since this test runs in the sphinx-codelinks repo) + assert src_analyse.git_root is not None + assert (src_analyse.git_root / ".git").exists() + + def test_oneline_parser_warnings_are_collected(tmp_path): """Test that oneline parser warnings are collected for later output.""" src_dir = TEST_DIR / "data" / "oneline_comment_default" src_paths = [src_dir / "default_oneliners.c"] - src_analyse_config = SourceAnalyseConfig( src_files=src_paths, src_dir=src_dir, @@ -156,7 +224,6 @@ def test_oneline_parser_warnings_are_collected(tmp_path): get_rst=False, oneline_comment_style=ONELINE_COMMENT_STYLE_DEFAULT, ) - src_analyse = SourceAnalyse(src_analyse_config) src_analyse.run() diff --git a/tests/test_cmd.py b/tests/test_cmd.py index 247e50d3..debc481f 100644 --- a/tests/test_cmd.py +++ b/tests/test_cmd.py @@ -320,3 +320,110 @@ def test_write_rst_negative(json_objs: list[dict], output_lines, tmp_path) -> No assert result.exit_code != 0 for line in output_lines: assert line in result.stdout + + +def test_analyse_with_relative_git_root(tmp_path: Path) -> None: + """Test that relative git_root is resolved relative to the config file location.""" + # Create a fake git repo structure + fake_git_root = tmp_path / "fake_repo" + fake_git_root.mkdir() + (fake_git_root / ".git").mkdir() + git_config = fake_git_root / ".git" / "config" + git_config.write_text( + '[remote "origin"]\n url = https://github.com/test/repo.git\n' + ) + git_head = fake_git_root / ".git" / "HEAD" + git_head.write_text("ref: refs/heads/main\n") + refs_dir = fake_git_root / ".git" / "refs" / "heads" + refs_dir.mkdir(parents=True) + (refs_dir / "main").write_text("abc123def456\n") + + # Create source file + src_dir = fake_git_root / "src" + src_dir.mkdir() + src_file = src_dir / "test.c" + src_file.write_text("// @Test, TEST_1, test\nvoid test() {}\n") + + # Create config in a subdirectory using a RELATIVE git_root path + config_dir = tmp_path / "config" + config_dir.mkdir() + config_file = config_dir / "codelinks.toml" + config_content = """ +[codelinks.projects.test_project.source_discover] +src_dir = "../fake_repo/src" +gitignore = false + +[codelinks.projects.test_project.analyse] +get_oneline_needs = true +git_root = "../fake_repo" +""" + config_file.write_text(config_content) + + outdir = tmp_path / "output" + outdir.mkdir() + + options = ["analyse", str(config_file), "--outdir", str(outdir)] + result = runner.invoke(app, options) + + assert result.exit_code == 0, f"CLI failed: {result.stdout}" + output_path = outdir / "marked_content.json" + assert output_path.exists() + + with output_path.open("r") as f: + marked_content = json.load(f) + # Verify the content was analysed using the correct git_root + assert len(marked_content["test_project"]) > 0 + + +def test_analyse_with_absolute_git_root(tmp_path: Path) -> None: + """Test that absolute git_root is used as-is.""" + # Create a fake git repo structure + fake_git_root = tmp_path / "fake_repo" + fake_git_root.mkdir() + (fake_git_root / ".git").mkdir() + git_config = fake_git_root / ".git" / "config" + git_config.write_text( + '[remote "origin"]\n url = https://github.com/test/repo.git\n' + ) + git_head = fake_git_root / ".git" / "HEAD" + git_head.write_text("ref: refs/heads/main\n") + refs_dir = fake_git_root / ".git" / "refs" / "heads" + refs_dir.mkdir(parents=True) + (refs_dir / "main").write_text("abc123def456\n") + + # Create source file + src_dir = fake_git_root / "src" + src_dir.mkdir() + src_file = src_dir / "test.c" + src_file.write_text("// @Test, TEST_2, test\nvoid test() {}\n") + + # Create config in a different location using an ABSOLUTE git_root path + config_dir = tmp_path / "config" + config_dir.mkdir() + config_file = config_dir / "codelinks.toml" + # Use absolute path for both src_dir and git_root + config_content = f""" +[codelinks.projects.test_project.source_discover] +src_dir = "{src_dir.as_posix()}" +gitignore = false + +[codelinks.projects.test_project.analyse] +get_oneline_needs = true +git_root = "{fake_git_root.as_posix()}" +""" + config_file.write_text(config_content) + + outdir = tmp_path / "output" + outdir.mkdir() + + options = ["analyse", str(config_file), "--outdir", str(outdir)] + result = runner.invoke(app, options) + + assert result.exit_code == 0, f"CLI failed: {result.stdout}" + output_path = outdir / "marked_content.json" + assert output_path.exists() + + with output_path.open("r") as f: + marked_content = json.load(f) + # Verify the content was analysed using the correct git_root + assert len(marked_content["test_project"]) > 0