[docs] 1# @Test suite for sources outside the roots their records are relative to, TEST_OUTSIDE_ROOTS_1, test, [IMPL_DISC_1]
2"""A source file outside the root its records are relative to is diagnosed (#2062).
3
4Two configurations put one there: a configured ``git_root`` that does not contain the
5project's ``src_dir`` (or does not exist), which is ignored with one
6``codelinks.git_root`` warning, the repository root being detected from ``src_dir``
7instead; and a discovered file that resolves to outside ``src_dir`` (a symbolic link out
8of the tree), which is not traced, with one ``codelinks.outside_src_dir`` warning
9naming the link. Every build case copies ``doc_test/need_id_refs`` (see
10``test_need_id_refs``).
11"""
12
13import json
14import ntpath
15import os
16import posixpath
17import re
18import subprocess
19import sys
20from pathlib import Path, PurePosixPath, PureWindowsPath
21from types import SimpleNamespace
22from typing import Any
23
24import pytest
25import sphinx
26from sphinx.testing.util import SphinxTestApp
27from typer.testing import CliRunner
28
29from sphinx_codelinks.analyse.references import NeedIdRef
30from sphinx_codelinks.cmd import app as cli
31from sphinx_codelinks.config import CodeLinksConfig, git_root_problem
32from sphinx_codelinks.source_discover import source_discover
33from sphinx_codelinks.source_discover.config import SourceDiscoverConfig
34from sphinx_codelinks.sphinx_extension.need_id_refs import (
35 need_id_refs_store,
36 project_roots,
37)
38from sphinx_codelinks.sphinx_extension.rediscovery import discover_scope
39from sphinx_needs_testkit import build_warnings
40
41from .test_need_id_refs import (
42 DANGLING,
43 FIXTURE,
44 _build,
45 _json,
46 _MakeApp,
47 _project,
48 _refs,
49 _url,
50)
51
52#: Sphinx 8 renders a warning's ``[type.subtype]`` itself; 7.4 does not
53_SHOWS_WARNING_TYPES = sphinx.version_info >= (8,)
54
55INDEX = (FIXTURE / "docs" / "index.rst").read_text(encoding="utf-8")
56DIRECTIVE = ".. src-trace::\n :project: src\n"
57assert DIRECTIVE in INDEX
58
59#: the file the links point at: a one-line need and a reference, so that a traced
60#: copy of it shows in the needs and in ``REQ_002``'s references alike
61OUTSIDE_SOURCE = "// @outside need, IMPL_OUTSIDE\n// @need-ids: REQ_002\n"
62
63LOCAL_ONLY = ("set_remote_url = true", "set_remote_url = false")
64REMOTE_ONLY = ("set_local_url = true", "set_local_url = false")
65
66
67#: what every ``codelinks.git_root`` warning says (Sphinx 7.4 renders no type suffix)
68IGNORED = "it is ignored, and the repository root is detected from src_dir instead"
69
70
71def _git_root(value: str) -> str:
72 return f'\n[codelinks.projects.src.analyse]\ngit_root = "{value}"\n'
73
74
75def _git_root_warning(root: Path, problem: str) -> str:
76 suffix = " [codelinks.git_root]" if _SHOWS_WARNING_TYPES else ""
77 return (
78 f"WARNING: project 'src': git_root {root.as_posix()} {problem}; it is "
79 "ignored, and the repository root is detected from src_dir instead"
80 f"{suffix}"
81 )
82
83
84def _outside_warning(link: Path, target: Path, src_dir: Path) -> str:
85 """The warning about ``link``, which leads it (the package logger appends the
86 type on Sphinx 7)."""
87 return (
88 f"WARNING: {link.as_posix()} resolves to "
89 f"{target.as_posix()}, outside src_dir {src_dir.as_posix()}: not traced "
90 "(widen src_dir to cover it, or exclude the link) [codelinks.outside_src_dir]"
91 )
92
93
94def _records(app: SphinxTestApp) -> list[NeedIdRef]:
95 """Every directive's records, in file and line order."""
96 records = [ref for refs in need_id_refs_store(app.env).values() for ref in refs]
97 return sorted(records, key=lambda ref: (ref.path, ref.lineno, ref.need_id))
98
99
100def _link_out(root: Path, link: str, target: str, *, directory: bool = False) -> None:
101 """Make ``root/link`` a symbolic link to ``root/target``."""
102 (root / link).symlink_to(root / target, target_is_directory=directory)
103
104
105# -- a git_root that does not contain src_dir ----------------------------------------
106
107
108def test_git_root_outside_src_dir_is_ignored_with_a_warning(
109 tmp_path: Path, make_app: _MakeApp
110) -> None:
111 """``git_root = "../other"`` exists but holds no source: one warning naming both
112 paths, and the records are relative to the repository detected from ``src_dir``
113 -- no ``../``, ``root == "git"``, and each round-trips through its dictionary."""
114 (tmp_path / "other").mkdir()
115 _project(tmp_path, toml_replace=LOCAL_ONLY, toml_extra=_git_root("../other"))
116 app = _build(tmp_path, make_app)
117
118 root = tmp_path.resolve()
119 assert build_warnings(app) == [
120 _git_root_warning(
121 root / "other", f"does not contain src_dir {(root / 'src').as_posix()}"
122 ),
123 DANGLING,
124 ]
125 records = _records(app)
126 assert records
127 assert {ref.path for ref in records} == {"src/refs.cpp"}
128 assert {ref.root for ref in records} == {"git"}
129 assert [NeedIdRef.from_dict(ref.to_dict()) for ref in records] == records
130 assert _refs(app)["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
131
132
133def test_git_root_outside_src_dir_with_remote_urls_uses_the_detected_root(
134 tmp_path: Path, make_app: _MakeApp
135) -> None:
136 """With remote URLs on the remote path is no longer a traceback: the URLs are
137 formed below the repository detected from ``src_dir``."""
138 (tmp_path / "other").mkdir()
139 commit = _project(tmp_path, toml_extra=_git_root("../other"))
140 app = _build(tmp_path, make_app)
141
142 root = tmp_path.resolve()
143 assert build_warnings(app) == [
144 _git_root_warning(
145 root / "other", f"does not contain src_dir {(root / 'src').as_posix()}"
146 ),
147 DANGLING,
148 ]
149 refs = _refs(app)
150 assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
151 assert refs["REQ_003"] == [_url(commit, 7)]
152
153
154def test_missing_git_root_is_ignored_with_a_warning(
155 tmp_path: Path, make_app: _MakeApp
156) -> None:
157 """A ``git_root`` naming no directory is the same warning class, and the build
158 goes on with the detected root."""
159 commit = _project(tmp_path, toml_extra=_git_root("../missing"))
160 app = _build(tmp_path, make_app)
161
162 assert build_warnings(app) == [
163 _git_root_warning(tmp_path.resolve() / "missing", "does not exist"),
164 DANGLING,
165 ]
166 assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
167 assert {ref.path for ref in _records(app)} == {"src/refs.cpp"}
168
169
170def test_the_attach_root_is_the_records_root(
171 tmp_path: Path, make_app: _MakeApp
172) -> None:
173 """The root the attach dedupes on is the one the records were made relative to:
174 both ignore the same ``git_root``."""
175 (tmp_path / "other").mkdir()
176 _project(tmp_path, toml_replace=LOCAL_ONLY, toml_extra=_git_root("../other"))
177 app = _build(tmp_path, make_app)
178
179 roots = project_roots(app.confdir, CodeLinksConfig.from_sphinx(app.config))
180 assert roots == {"src": tmp_path.resolve().as_posix()}
181
182
183@pytest.mark.parametrize("accepted", ["src_dir", "above_the_repository"])
184def test_an_accepted_git_root_is_the_records_and_the_attachs_root(
185 tmp_path: Path, make_app: _MakeApp, accepted: str
186) -> None:
187 """A ``git_root`` other than the detected repository -- ``src_dir`` itself, or a
188 directory above the repository that is no repository -- is used by the analysis
189 AND by the attach: the records are relative to it, and ``project_roots()`` names
190 it."""
191 project = tmp_path / "proj"
192 project.mkdir()
193 value, root, path = {
194 "src_dir": ("../src", project / "src", "refs.cpp"),
195 "above_the_repository": (tmp_path.as_posix(), tmp_path, "proj/src/refs.cpp"),
196 }[accepted]
197 _project(project, toml_replace=LOCAL_ONLY, toml_extra=_git_root(value))
198 app = _build(project, make_app)
199
200 assert not any(IGNORED in w for w in build_warnings(app))
201 records = _records(app)
202 assert records
203 assert {ref.path for ref in records} == {path}
204 assert {ref.root for ref in records} == {"git"}
205 roots = project_roots(app.confdir, CodeLinksConfig.from_sphinx(app.config))
206 assert roots == {"src": root.resolve().as_posix()}
207 assert (Path(roots["src"]) / path).resolve() == (
208 project / "src" / "refs.cpp"
209 ).resolve()
210
211
212@pytest.mark.skipif(
213 sys.platform == "win32" or (hasattr(os, "geteuid") and os.geteuid() == 0),
214 reason="needs POSIX permissions that bind the running user",
215)
216def test_an_unreadable_git_root_is_ignored_with_a_warning(
217 tmp_path: Path, make_app: _MakeApp
218) -> None:
219 """A ``git_root`` below a directory that cannot be read is treated as unset, as a
220 missing one is, instead of failing the build at ``config-inited``."""
221 project = tmp_path / "proj"
222 project.mkdir()
223 locked = tmp_path / "locked"
224 (locked / "inner").mkdir(parents=True)
225 _project(
226 project,
227 toml_replace=LOCAL_ONLY,
228 toml_extra=_git_root((locked / "inner").as_posix()),
229 )
230 locked.chmod(0)
231 try:
232 app = _build(project, make_app)
233 finally:
234 locked.chmod(0o755)
235
236 assert [w for w in build_warnings(app) if IGNORED in w] == [
237 _git_root_warning(locked.resolve() / "inner", "cannot be read")
238 ]
239 assert {ref.path for ref in _records(app)} == {"src/refs.cpp"}
240
241
242def test_a_git_root_above_src_dir_changes_nothing(
243 tmp_path: Path, make_app: _MakeApp
244) -> None:
245 """``git_root = ".."`` -- the repository, an ancestor of ``src_dir`` -- warns
246 nothing new, and the records and URLs are those of a build without it."""
247 commit = _project(tmp_path)
248 detected = _build(tmp_path, make_app)
249 toml = tmp_path / "docs" / "ubproject.toml"
250 toml.write_text(
251 toml.read_text(encoding="utf-8") + _git_root(".."), encoding="utf-8"
252 )
253 configured = _build(tmp_path, make_app)
254
255 # (a second application in one test re-registers Sphinx's nodes, and says so)
256 assert [w for w in build_warnings(configured) if "already registered" not in w] == [
257 DANGLING
258 ]
259 assert _refs(configured) == _refs(detected)
260 assert _refs(configured)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
261 assert _records(configured) == _records(detected)
262 assert project_roots(
263 configured.confdir, CodeLinksConfig.from_sphinx(configured.config)
264 ) == {"src": tmp_path.resolve().as_posix()}
265
266
267# -- a file that resolves to outside src_dir -----------------------------------------
268
269
270@pytest.mark.parametrize(
271 "urls",
272 [
273 pytest.param(LOCAL_ONLY, id="local"),
274 pytest.param(REMOTE_ONLY, id="remote"),
275 pytest.param(None, id="both"),
276 ],
277)
278def test_a_file_link_out_of_src_dir_is_not_traced(
279 tmp_path: Path, make_app: _MakeApp, urls: tuple[str, str] | None
280) -> None:
281 """``src/ext_link.cpp`` -> ``outside/ext.cpp``: the build succeeds, the file is
282 neither traced nor referenced, and one warning names the link."""
283 _project(tmp_path, toml_replace=urls, files={"outside/ext.cpp": OUTSIDE_SOURCE})
284 _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
285 app = _build(tmp_path, make_app)
286
287 root = tmp_path.resolve()
288 assert build_warnings(app) == [
289 _outside_warning(
290 root / "src" / "ext_link.cpp", root / "outside" / "ext.cpp", root / "src"
291 ),
292 DANGLING,
293 ]
294 assert "IMPL_OUTSIDE" not in _json(app)["needs"]
295 assert {ref.path for ref in _records(app)} == {"src/refs.cpp"}
296
297
298@pytest.mark.parametrize(
299 ("show", "expected"),
300 [
301 # Sphinx renders the suffix itself, so the package logger adds none
302 pytest.param(True, ["[codelinks.outside_src_dir]"], id="on"),
303 # before Sphinx 8 the package logger appends it; from 8 nothing renders it
304 pytest.param(
305 False,
306 [] if _SHOWS_WARNING_TYPES else ["[codelinks.outside_src_dir]"],
307 id="off",
308 ),
309 ],
310)
311def test_the_type_suffix_is_rendered_at_most_once(
312 tmp_path: Path, make_app: _MakeApp, show: bool, expected: list[str]
313) -> None:
314 """#2091: on Sphinx 7.4 with the option on, it was rendered twice."""
315 conf = {"docs/conf.py": f"\nshow_warning_types = {show}\n"}
316 _project(tmp_path, files={"outside/ext.cpp": OUTSIDE_SOURCE}, append=conf)
317 _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
318 outside = [w for w in build_warnings(_build(tmp_path, make_app)) if "outside" in w]
319 assert [re.findall(r"\[codelinks\.[a-z_]+\]", w) for w in outside] == [expected]
320
321
322def test_a_link_out_is_warned_once_when_its_scope_is_walked_again(
323 tmp_path: Path, make_app: _MakeApp
324) -> None:
325 """A file added to the scope re-reads ``index``: the scope is walked twice in that
326 build (the fingerprint, then the directive), and the link is warned once."""
327 _project(tmp_path, files={"outside/ext.cpp": OUTSIDE_SOURCE})
328 _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
329 _build(tmp_path, make_app)
330 (tmp_path / "src" / "added.cpp").write_text(
331 "// @need-ids: REQ_001\n", encoding="utf-8"
332 )
333 app = _build(tmp_path, make_app, freshenv=False)
334
335 outside = [w for w in build_warnings(app) if "codelinks.outside_src_dir" in w]
336 assert len(outside) == 1, build_warnings(app)
337 assert "IMPL_OUTSIDE" not in _json(app)["needs"]
338
339
340def test_a_followed_directory_link_out_of_src_dir_traces_none_of_its_files(
341 tmp_path: Path, make_app: _MakeApp
342) -> None:
343 """``follow_links = true`` and ``src/dirlink`` -> ``outside/odir``: each file
344 below the link is warned once, by its path through the link, and none is traced."""
345 _project(
346 tmp_path,
347 toml_replace=(
348 'comment_type = "cpp"',
349 'comment_type = "cpp"\nfollow_links = true',
350 ),
351 files={
352 "outside/odir/one.cpp": OUTSIDE_SOURCE,
353 "outside/odir/two.cpp": "// @other outside need, IMPL_OUTSIDE_2\n",
354 },
355 )
356 _link_out(tmp_path, "src/dirlink", "outside/odir", directory=True)
357 app = _build(tmp_path, make_app)
358
359 root = tmp_path.resolve()
360 outside = sorted(w for w in build_warnings(app) if "outside_src_dir" in w)
361 assert outside == [
362 _outside_warning(
363 root / "src" / "dirlink" / name,
364 root / "outside" / "odir" / name,
365 root / "src",
366 )
367 for name in ("one.cpp", "two.cpp")
368 ]
369 needs = _json(app)["needs"]
370 assert "IMPL_OUTSIDE" not in needs
371 assert "IMPL_OUTSIDE_2" not in needs
372 assert {ref.path for ref in _records(app)} == {"src/refs.cpp"}
373
374
375def test_a_file_scope_naming_a_link_out_of_src_dir_traces_nothing(
376 tmp_path: Path, make_app: _MakeApp
377) -> None:
378 """``:file: ext_link.cpp`` names an existing file outside ``src_dir``: the scope
379 is empty, the link is warned, and the build goes on."""
380 file_scope = ".. src-trace::\n :project: src\n :file: ext_link.cpp\n"
381 _project(
382 tmp_path,
383 files={
384 "outside/ext.cpp": OUTSIDE_SOURCE,
385 "docs/index.rst": INDEX.replace(DIRECTIVE, file_scope),
386 },
387 )
388 _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
389 app = _build(tmp_path, make_app)
390
391 root = tmp_path.resolve()
392 assert build_warnings(app) == [
393 _outside_warning(
394 root / "src" / "ext_link.cpp", root / "outside" / "ext.cpp", root / "src"
395 )
396 ]
397 assert "IMPL_OUTSIDE" not in _json(app)["needs"]
398 assert _records(app) == []
399
400
401def test_a_config_only_project_skips_a_link_out_and_attaches_the_rest(
402 tmp_path: Path, make_app: _MakeApp
403) -> None:
404 """No directive traces ``src``: the configuration pass walks it through the same
405 discovery, warns about the link, and attaches every other reference."""
406 commit = _project(
407 tmp_path,
408 files={
409 "outside/ext.cpp": OUTSIDE_SOURCE,
410 "docs/index.rst": INDEX.replace(DIRECTIVE, ""),
411 },
412 )
413 _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
414 app = _build(tmp_path, make_app)
415
416 root = tmp_path.resolve()
417 assert build_warnings(app) == [
418 _outside_warning(
419 root / "src" / "ext_link.cpp", root / "outside" / "ext.cpp", root / "src"
420 ),
421 DANGLING,
422 ]
423 refs = _refs(app)
424 assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
425 assert refs["REQ_002"] == [_url(commit, 3)]
426
427
428def test_the_cli_discover_lists_no_file_outside_src_dir(tmp_path: Path) -> None:
429 """``codelinks discover`` lists the files inside and warns on stderr."""
430 (tmp_path / "src").mkdir()
431 (tmp_path / "outside").mkdir()
432 (tmp_path / "src" / "a.cpp").write_text("// a\n", encoding="utf-8")
433 (tmp_path / "outside" / "ext.cpp").write_text("// ext\n", encoding="utf-8")
434 _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
435
436 result = CliRunner().invoke(
437 cli, ["discover", str(tmp_path / "src"), "--no-gitignore"]
438 )
439
440 assert result.exit_code == 0, result.output
441 root = tmp_path.resolve()
442 assert result.stdout.splitlines() == [
443 "1 files discovered",
444 str(root / "src" / "a.cpp"),
445 ]
446 stderr = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\s+", "", result.stderr)
447 assert (
448 f"{(root / 'src' / 'ext_link.cpp').as_posix()}resolvesto"
449 f"{(root / 'outside' / 'ext.cpp').as_posix()},outsidesrc_dir"
450 f"{(root / 'src').as_posix()}:nottraced"
451 ) in stderr
452
453
454def test_a_link_leaving_a_directory_scope_but_not_src_dir_is_traced(
455 tmp_path: Path,
456) -> None:
457 """The rule names the PROJECT's ``src_dir``, not the scope's directory: a link in
458 ``:directory: a`` to ``b/x.cpp`` below the same ``src_dir`` is found, as it was."""
459 src = tmp_path / "src"
460 (src / "a").mkdir(parents=True)
461 (src / "b").mkdir()
462 (src / "b" / "x.cpp").write_text("// x\n", encoding="utf-8")
463 _link_out(tmp_path, "src/a/x_link.cpp", "src/b/x.cpp")
464 base = SourceDiscoverConfig(src_dir=src, gitignore=False)
465
466 found = discover_scope(src.resolve(), base, "directory", "a", exclude=())
467
468 assert found == [(src / "b" / "x.cpp").resolve()]
469
470
471@pytest.mark.parametrize(
472 ("flavour", "path", "directory", "expected"),
473 [
474 (posixpath, "/src2/x.cpp", "/src", False),
475 (posixpath, "/src/x.cpp", "/src", True),
476 (posixpath, "/src/sub/x.cpp", "/src", True),
477 (posixpath, "/src", "/src", False),
478 (posixpath, "/x.cpp", "/", True),
479 (posixpath, "/SRC/x.cpp", "/src", False),
480 (ntpath, "C:\\src2\\x.cpp", "C:\\src", False),
481 (ntpath, "c:\\SRC\\x.cpp", "C:\\src", True),
482 (ntpath, "C:\\x.cpp", "C:\\", True),
483 (ntpath, "\\\\server\\share\\src\\x.cpp", "\\\\server\\share\\src", True),
484 (ntpath, "C:/src/x.cpp", "C:\\src", True),
485 (ntpath, "D:\\src\\x.cpp", "C:\\src", False),
486 ],
487)
488def test_lies_within(
489 monkeypatch: pytest.MonkeyPatch,
490 flavour: Any,
491 path: str,
492 directory: str,
493 expected: bool,
494) -> None:
495 """Below the directory, never beside it (``src2`` is not below ``src``), with the
496 platform's case folding; a path equal to the directory is no file below it."""
497 monkeypatch.setattr(source_discover, "os", SimpleNamespace(path=flavour))
498 pure = PureWindowsPath if flavour is ntpath else PurePosixPath
499
500 assert source_discover.lies_within(pure(path), pure(directory)) is expected
501
502
503def _repository(root: Path, remote: str) -> str:
504 """Make ``root`` a repository with ``remote`` as ``origin``; return its commit."""
505 git = ["git", "-c", "user.name=t", "-c", "user.email=t@t"]
506 git += ["-c", "commit.gpgsign=false"]
507 root.mkdir(parents=True, exist_ok=True)
508 for args in (
509 ["init", "--quiet"],
510 ["remote", "add", "origin", remote],
511 ["commit", "--quiet", "--allow-empty", "-m", "init"],
512 ):
513 subprocess.run([*git, *args], cwd=root, check=True, capture_output=True)
514 return subprocess.run(
515 ["git", "rev-parse", "HEAD"],
516 cwd=root,
517 check=True,
518 capture_output=True,
519 text=True,
520 ).stdout.strip()
521
522
523@pytest.mark.parametrize(
524 ("git_root", "problem"),
525 [
526 pytest.param("../other", "does not contain src_dir {src}", id="sibling"),
527 pytest.param("../missing", "does not exist", id="missing"),
528 ],
529)
530def test_the_cli_analyse_ignores_a_git_root_that_does_not_contain_src_dir(
531 tmp_path: Path, git_root: str, problem: str
532) -> None:
533 """``codelinks analyse`` applies the build's rule: one warning on stderr, and the
534 remote URLs formed below the repository detected from ``src_dir``."""
535 project = tmp_path / "proj"
536 (project / "src").mkdir(parents=True)
537 (project / "src" / "a.cpp").write_text(
538 "// @need-ids: REQ_1\nvoid f() {}\n", encoding="utf-8"
539 )
540 (project / "cl.toml").write_text(
541 "[codelinks.projects.p.source_discover]\n"
542 'src_dir = "src"\ncomment_type = "cpp"\ngitignore = false\n'
543 "[codelinks.projects.p.analyse]\n"
544 f'git_root = "{git_root}"\n',
545 encoding="utf-8",
546 )
547 commit = _repository(project, "https://github.com/example/demo.git")
548 _repository(tmp_path / "other", "https://github.com/example/other.git")
549 out = tmp_path / "out"
550 out.mkdir()
551
552 result = CliRunner().invoke(
553 cli, ["analyse", str(project / "cl.toml"), "--outdir", str(out)]
554 )
555
556 assert result.exit_code == 0, result.output
557 root = tmp_path.resolve()
558 src = (root / "proj" / "src").as_posix()
559 named = (root / git_root.removeprefix("../")).as_posix()
560 expected = (
561 f"project 'p': git_root {named} {problem.format(src=src)}; it is ignored, "
562 "and the repository root is detected from src_dir instead"
563 )
564 stderr = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\s+", "", result.stderr)
565 assert re.sub(r"\s+", "", expected) in stderr
566 markers = json.loads((out / "marked_content.json").read_text(encoding="utf-8"))
567 assert [marker["remote_url"] for marker in markers["p"]] == [
568 f"https://github.com/example/demo/blob/{commit}/src/a.cpp#L1"
569 ]
570
571
572def test_git_root_problem(tmp_path: Path) -> None:
573 """The one check both the build and ``codelinks analyse`` apply, row by row."""
574 src = tmp_path / "repo" / "src"
575 src.mkdir(parents=True)
576 (tmp_path / "file").write_text("", encoding="utf-8")
577 (tmp_path / "other").mkdir()
578 root = tmp_path.resolve()
579 src = src.resolve()
580
581 assert git_root_problem(root / "repo", src) is None
582 assert git_root_problem(src, src) is None
583 assert git_root_problem(root, src) is None
584 assert git_root_problem(root / "missing", src) == (
585 f"git_root {(root / 'missing').as_posix()} does not exist"
586 )
587 assert git_root_problem(root / "file", src) == (
588 f"git_root {(root / 'file').as_posix()} is not a directory"
589 )
590 assert git_root_problem(root / "other", src) == (
591 f"git_root {(root / 'other').as_posix()} does not contain src_dir "
592 f"{src.as_posix()}"
593 )