Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/source/components/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "@"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/sphinx_codelinks/analyse/analyse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
8 changes: 7 additions & 1 deletion src/sphinx_codelinks/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 13 additions & 3 deletions src/sphinx_codelinks/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions src/sphinx_codelinks/sphinx_extension/directives/src_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
71 changes: 69 additions & 2 deletions tests/test_analyse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()

Expand Down
107 changes: 107 additions & 0 deletions tests/test_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading