1import configparser
  2from collections.abc import Callable
  3from pathlib import Path
  4from urllib.request import pathname2url
  5
  6from giturlparse import parse
  7from tree_sitter import Language, Parser, Query, QueryCursor
  8from tree_sitter import Node as TreeSitterNode
  9
 10from sphinx_codelinks.config import CommentCategory
 11from sphinx_codelinks.logger import get_logger
 12from sphinx_codelinks.source_discover.config import CommentType
 13
 14# Language-specific node types for scope detection.
 15#
 16# YAML and JSONC are intentionally absent. They are data formats, not code, so a
 17# comment associates with the surrounding data structure (key/value pair, list
 18# item, or scalar) rather than with an enclosing or following declaration. That
 19# needs a different algorithm (inline same-row association first, scalar targets,
 20# grammar-specific traversal), implemented in find_yaml_associated_structure and
 21# find_jsonc_associated_structure and dispatched from find_associated_scope.
 22# Those bespoke finders never read this table (only find_next_scope and
 23# find_enclosing_scope do), so an entry here would be dead.
 24SCOPE_NODE_TYPES = {
[docs] 25    # @Python Scope Node Types, IMPL_PY_2, impl, [FE_PY]
 26    CommentType.python: {"function_definition", "class_definition"},
[docs] 27    # @C and C++ Scope Node Types, IMPL_C_2, impl, [FE_C_SUPPORT, FE_CPP]
 28    CommentType.cpp: {"function_definition", "class_specifier", "struct_specifier"},
 29    CommentType.cs: {"method_declaration", "class_declaration", "property_declaration"},
[docs] 30    # @TypeScript Scope Node Types, IMPL_TS_2, impl, [FE_TS]
 31    # The last five are scopes only when they hold a function or a class: see
 32    # SCOPE_PREDICATES.
 33    CommentType.ts: {
 34        "function_declaration",
 35        "generator_function_declaration",
 36        "function_signature",
 37        "class_declaration",
 38        "abstract_class_declaration",
 39        "interface_declaration",
 40        "type_alias_declaration",
 41        "enum_declaration",
 42        "module",
 43        "internal_module",
 44        "method_definition",
 45        "method_signature",
 46        "abstract_method_signature",
 47        "lexical_declaration",
 48        "variable_declaration",
 49        "public_field_definition",
 50        "assignment_expression",
 51        "export_statement",
 52    },
[docs] 53    # @Rust Scope Node Types, IMPL_RUST_2, impl, [FE_RUST]
 54    CommentType.rust: {
 55        "function_item",
 56        "struct_item",
 57        "enum_item",
 58        "impl_item",
 59        "trait_item",
 60        "mod_item",
 61    },
[docs] 62    # @Go Scope Node Types, IMPL_GO_4, impl, [FE_GO]
 63    CommentType.go: {
 64        "function_declaration",
 65        "method_declaration",
 66        "type_declaration",
 67        "type_spec",
 68    },
[docs] 69    # @Bash Scope Node Types, IMPL_BASH_2, impl, [FE_BASH]
 70    CommentType.bash: {"function_definition"},
 71}
 72
 73logger = get_logger(__name__)
 74
 75GIT_HOST_URL_TEMPLATE = {
 76    "github": "https://github.com/{owner}/{repo}/blob/{rev}/{path}#L{lineno}",
 77    "gitlab": "https://gitlab.com/{owner}/{repo}/-/blob/{rev}/{path}#L{lineno}",
 78}
 79
 80PYTHON_QUERY = """
 81                ; Match comments
 82                (comment) @comment
 83
 84                ; Match docstrings inside modules, functions, or classes
 85                (module (expression_statement (string)) @comment)
 86                (function_definition (block (expression_statement (string)) @comment))
 87                (class_definition (block (expression_statement (string)) @comment))
 88            """
 89# The query of every grammar whose comments are one ``comment`` node kind.
 90SIMPLE_COMMENT_QUERY = """(comment) @comment"""
 91# ``html_comment`` is a separate node kind the TypeScript/TSX grammars emit
 92# for ``<!--`` comments: legacy one-line comments, valid in classic scripts only.
 93# Without matching it, markers written in that style are silently dropped.
 94TYPE_SCRIPT_QUERY = """
 95    (comment) @comment
 96    (html_comment) @comment
 97"""
 98RUST_QUERY = """
 99    (line_comment) @comment
100    (block_comment) @comment
101"""
102
103# JSON value node types that can be associated with a comment.
104JSON_STRUCTURE_TYPES = {
105    "pair",
106    "object",
107    "array",
108    "string",
109    "number",
110    "true",
111    "false",
112    "null",
113}
114
115# TypeScript's own module variants. Legacy angle-bracket type assertions
116# (``<T>x``) are valid syntax only here, not under the TSX grammar (see the
117# CommentType.ts branch of init_tree_sitter for what goes wrong otherwise), so
118# these three suffixes get the plain TypeScript grammar and everything else in
119# the JS/TS family falls back to TSX.
120TS_STRICT_GRAMMAR_SUFFIXES = {".ts", ".mts", ".cts"}
121
122
123def ts_grammar_key(src_path: Path) -> str:
124    """Return which tree-sitter-typescript grammar ``src_path`` needs.
125
126    ``"typescript"`` for TypeScript's own module variants (``.ts``, ``.mts``,
127    ``.cts``); ``"tsx"`` for the rest of the JavaScript/TypeScript family
128    (``.tsx``, ``.jsx``, ``.js``, ``.mjs``, ``.cjs``). Used both to pick the
129    grammar in ``init_tree_sitter`` and, by callers that parse many files, to
130    cache one parser per grammar instead of rebuilding one per file.
131    """
132    return (
133        "typescript" if src_path.suffix.lower() in TS_STRICT_GRAMMAR_SUFFIXES else "tsx"
134    )
135
136
137def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
138    """Return True if file is likely text, False if binary."""
139    try:
140        with filepath.open("rb") as f:
141            chunk = f.read(sample_size)
142        # Quick binary heuristic: null byte present
143        if b"\x00" in chunk:
144            return False
145        # Try UTF-8 decode on the sample
146        chunk.decode("utf-8")
147        return True
148    except UnicodeDecodeError:
149        return False
150
151
[docs]152# @Tree-sitter parser initialization for multiple languages, IMPL_LANG_1, impl, [FE_C_SUPPORT, FE_CPP, FE_PY, FE_YAML, FE_RUST, FE_GO, FE_JSONC, FE_BASH, FE_TS]
153def init_tree_sitter(
154    comment_type: CommentType, src_path: Path | None = None
155) -> tuple[Parser, Query]:
156    """Build the (parser, query) pair for ``comment_type``.
157
158    ``src_path`` only matters for ``CommentType.ts``, whose grammar varies by
159    file suffix (see ``ts_grammar_key``); every other comment type ignores it
160    and uses a single grammar. When ``src_path`` is omitted the TSX grammar is
161    assumed, which is the safe default for the whole JS/TS family except
162    TypeScript's own module variants.
163    """
164    if comment_type == CommentType.cpp:
165        import tree_sitter_cpp
166
167        parsed_language = Language(tree_sitter_cpp.language())
168        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
169    elif comment_type == CommentType.python:
170        import tree_sitter_python
171
172        parsed_language = Language(tree_sitter_python.language())
173        query = Query(parsed_language, PYTHON_QUERY)
174    elif comment_type == CommentType.cs:
175        import tree_sitter_c_sharp
176
177        parsed_language = Language(tree_sitter_c_sharp.language())
178        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
179    elif comment_type == CommentType.ts:
[docs]180        # @TypeScript comment query for tree-sitter, IMPL_TS_3, impl, [FE_TS]
181        import tree_sitter_typescript
182
183        # Legacy angle-bracket type assertions (``<T>x``) are valid TypeScript
184        # syntax in .ts/.mts/.cts, but the same text is JSX syntax under the TSX
185        # grammar: ``<string>x`` parses as a jsx_opening_element and swallows the
186        # rest of the file into a single jsx_text node, silently dropping every
187        # marker after it. So the plain TypeScript grammar is required for those
188        # three suffixes. The TSX grammar remains the fallback for the rest of
189        # the JS/TS family (.tsx, .jsx, .js, .mjs, .cjs): JavaScript has no such
190        # cast syntax, so TSX is safe there, and it additionally handles JSX
191        # embedded in plain .js.
192        if src_path is not None and ts_grammar_key(src_path) == "typescript":
193            parsed_language = Language(tree_sitter_typescript.language_typescript())
194        else:
195            parsed_language = Language(tree_sitter_typescript.language_tsx())
196        query = Query(parsed_language, TYPE_SCRIPT_QUERY)
197    elif comment_type == CommentType.yaml:
198        import tree_sitter_yaml
199
200        parsed_language = Language(tree_sitter_yaml.language())
201        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
202    elif comment_type == CommentType.rust:
203        import tree_sitter_rust
204
205        parsed_language = Language(tree_sitter_rust.language())
206        query = Query(parsed_language, RUST_QUERY)
207    elif comment_type == CommentType.go:
[docs]208        # @Go comment query for tree-sitter, IMPL_GO_3, impl, [FE_GO]
209        import tree_sitter_go
210
211        parsed_language = Language(tree_sitter_go.language())
212        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
213    elif comment_type == CommentType.jsonc:
214        import tree_sitter_json
215
216        parsed_language = Language(tree_sitter_json.language())
217        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
218    elif comment_type == CommentType.bash:
[docs]219        # @Bash comment query for tree-sitter, IMPL_BASH_3, impl, [FE_BASH]
220        import tree_sitter_bash
221
222        parsed_language = Language(tree_sitter_bash.language())
223        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
224    else:
225        raise ValueError(f"Unsupported comment style: {comment_type}")
226    parser = Parser(parsed_language)
227    return parser, query
228
229
[docs]230# @Comment extraction from source code using tree-sitter, IMPL_EXTR_1, impl, [FE_DEF]
231def extract_comments(
232    src_string: bytes, parser: Parser, query: Query
233) -> list[TreeSitterNode] | None:
234    """Get all comments from source files by tree-sitter.
235
236    The whole buffer is parsed in one piece: read one byte at a time, a multi-byte
237    UTF-8 character inside a token (a YAML scalar, an identifier, a Rust, Go or C#
238    character literal) is lexed as invalid, which loses every YAML comment after it
239    and the scope a marker binds to in bash, YAML or Go. Do not reintroduce a read
240    callback: a chunk boundary inside a character breaks the parse the same way (a
241    from-offset chunk of four bytes or more happens to work; a block-aligned one
242    does not).
243    """
244    tree = parser.parse(src_string)
245    query_cursor = QueryCursor(query)
246    captures: dict[str, list[TreeSitterNode]] = query_cursor.captures(tree.root_node)
247
248    return captures.get("comment")
249
250
251# What a TypeScript declaration's value can be wrapped in without changing what it
252# is: ``(() => {}) as Handler``, ``<Handler>(() => {})``, ``f!``, ``x satisfies T``.
253TS_VALUE_WRAPPER_TYPES = {
254    "parenthesized_expression",
255    "as_expression",
256    "satisfies_expression",
257    "non_null_expression",
258    "type_assertion",
259}
260# The values that make a declaration a scope.
261TS_FUNCTION_VALUE_TYPES = {
262    "arrow_function",
263    "function_expression",
264    "generator_function",
265    "class",
266}
267
268
269def _is_function_like(value: TreeSitterNode | None) -> bool:
270    """True if ``value``, seen through its wrappers, is a function or a class."""
271    while value is not None and value.type in TS_VALUE_WRAPPER_TYPES:
272        children = value.named_children
273        if not children:
274            return False
275        # ``<T>x`` names its type first, every other wrapper its expression
276        value = children[-1] if value.type == "type_assertion" else children[0]
277    return value is not None and value.type in TS_FUNCTION_VALUE_TYPES
278
279
280def _declares_function(node: TreeSitterNode) -> bool:
281    """A ``const``/``let``/``var`` declaration one of whose declarators is a function,
282    or one declared with ``declare`` (``declare const x: T``), which holds no value and
283    is the declared thing itself. Only the ``declare``-prefixed form: a variable inside a
284    ``declare namespace``, ``module`` or ``global`` body, or a bare ``export const x: T;``
285    in a ``.d.ts``, is no scope here and binds the next declaration."""
286    if node.parent is not None and node.parent.type == "ambient_declaration":
287        return True
288    return any(
289        _is_function_like(declarator.child_by_field_name("value"))
290        for declarator in node.named_children
291        if declarator.type == "variable_declarator"
292    )
293
294
295def _value_is_function(node: TreeSitterNode) -> bool:
296    """A class field (``handler = () => {}``) or a default export of a function or class."""
297    return _is_function_like(node.child_by_field_name("value"))
298
299
300def _assigns_function(node: TreeSitterNode) -> bool:
301    """An assignment of a function, as ``module.exports = function () {}`` is."""
302    return _is_function_like(node.child_by_field_name("right"))
303
304
305# Node types a scope can be wrapped in, per language: when the next sibling of a comment
306# is one of them, its children are searched for the scope. Every language has ``block``.
307DEFAULT_SCOPE_CONTAINER_TYPES = frozenset({"block"})
308SCOPE_CONTAINER_TYPES: dict[CommentType, frozenset[str]] = {
309    # ``export …``, ``declare …``, and an expression statement holding an assignment
310    # or a top-level ``namespace``
311    CommentType.ts: DEFAULT_SCOPE_CONTAINER_TYPES
312    | {"export_statement", "ambient_declaration", "expression_statement"},
313}
314
315# Scope node types that are a scope only when their predicate holds, per language, so
316# that a comment does not bind to a declaration that merely precedes the one it
317# documents (``const flag = true;`` before a function).
318SCOPE_PREDICATES: dict[CommentType, dict[str, Callable[[TreeSitterNode], bool]]] = {
319    CommentType.ts: {
320        "lexical_declaration": _declares_function,
321        "variable_declaration": _declares_function,
322        "public_field_definition": _value_is_function,
323        "assignment_expression": _assigns_function,
324        "export_statement": _value_is_function,
325    },
326}
327
328
329def _matches_scope(node: TreeSitterNode, comment_type: CommentType) -> bool:
330    if node.type not in SCOPE_NODE_TYPES.get(comment_type, ()):
331        return False
332    predicate = SCOPE_PREDICATES.get(comment_type, {}).get(node.type)
333    return predicate is None or predicate(node)
334
335
336def find_enclosing_scope(
337    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
338) -> TreeSitterNode | None:
339    """Find the enclosing scope of a comment."""
340    current: TreeSitterNode = node
341    while current:
342        if _matches_scope(current, comment_type):
343            return current
344        current: TreeSitterNode | None = current.parent  # required for node traversal
345    return None
346
347
348def find_next_scope(
349    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
350) -> TreeSitterNode | None:
351    """Find the next scope of a comment."""
352    containers = SCOPE_CONTAINER_TYPES.get(comment_type, DEFAULT_SCOPE_CONTAINER_TYPES)
353    current: TreeSitterNode = node
354    while current:
355        if _matches_scope(current, comment_type):
356            return current
357        current: TreeSitterNode | None = (
358            current.next_named_sibling
359        )  # required for node traversal
360        if current and current.type in containers:
361            for child in current.named_children:
362                if _matches_scope(child, comment_type):
363                    return child
364                # a container in a container, as ``export declare …`` nests an
365                # ``ambient_declaration`` in an ``export_statement``
366                if child.type in containers and child.type != "block":
367                    for grandchild in child.named_children:
368                        if _matches_scope(grandchild, comment_type):
369                            return grandchild
370
371    return None
372
373
374def _find_yaml_structure_in_block_node(
375    block_node: TreeSitterNode,
376) -> TreeSitterNode | None:
377    """Find YAML structure elements within a block_node."""
378    for grandchild in block_node.named_children:
379        if grandchild.type == "block_mapping":
380            for ggchild in grandchild.named_children:
381                if ggchild.type == "block_mapping_pair":
382                    return ggchild
383        elif grandchild.type == "block_sequence":
384            for ggchild in grandchild.named_children:
385                if ggchild.type == "block_sequence_item":
386                    return ggchild
387    return None
388
389
390def find_yaml_next_structure(node: TreeSitterNode) -> TreeSitterNode | None:
391    """Find the next YAML structure element after the comment node."""
392    current = node.next_named_sibling
393    while current:
394        if current.type in {
395            "block_mapping_pair",
396            "block_sequence_item",
397            "flow_mapping",
398            "flow_sequence",
399        }:
400            return current
401        if current.type == "document":
402            for child in current.named_children:
403                if child.type == "block_node":
404                    result = _find_yaml_structure_in_block_node(child)
405                    if result:
406                        return result
407        if current.type == "block_node":
408            result = _find_yaml_structure_in_block_node(current)
409            if result:
410                return result
411        current = current.next_named_sibling
412    return None
413
414
415def find_prev_sibling_on_same_row(node: TreeSitterNode) -> TreeSitterNode | None:
416    """Find a previous named sibling that is on the same row as the comment.
417
418    Grammar-agnostic: used to detect inline comments in both YAML and JSONC.
419    """
420    comment_row = node.start_point.row
421    current = node.prev_named_sibling
422
423    while current:
424        # Check if this sibling ends on the same row as the comment starts
425        # This indicates it's an inline comment
426        if current.end_point.row == comment_row:
427            return current
428        # If we find a sibling that ends before the comment row, we can stop
429        # as we won't find any siblings on the same row going backwards
430        if current.end_point.row < comment_row:
431            break
432        current = current.prev_named_sibling
433
434    return None
435
436
437def find_yaml_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
438    """Find the YAML structure (key-value pair, list item, etc.) associated with a comment."""
439    # First, check if this is an inline comment by looking for a previous sibling on the same row
440    prev_sibling_same_row = find_prev_sibling_on_same_row(node)
441    if prev_sibling_same_row:
442        return prev_sibling_same_row
443
444    # If no previous sibling on same row, try to find the next named sibling (structure after the comment)
445    structure = find_yaml_next_structure(node)
446    if structure:
447        return structure
448
449    # If no next sibling found, traverse up to find parent structure
450    parent = node.parent
451    while parent:
452        if parent.type in {"block_mapping_pair", "block_sequence_item"}:
453            return parent
454        parent = parent.parent
455
456    return None
457
458
[docs]459# @JSONC comment-to-structure association, IMPL_JSONC_2, impl, [FE_JSONC]
460def find_jsonc_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
461    """Find the JSON structure (key/value pair, value, list item) for a comment.
462
463    JSON is data rather than code, so association follows the same intent as YAML:
464    an inline comment belongs to the value on its row, a leading comment belongs to
465    the following structure, otherwise it belongs to the enclosing structure.
466    """
467    # Inline comment: a value/pair on the same row, before the comment
468    prev_sibling_same_row = find_prev_sibling_on_same_row(node)
469    if prev_sibling_same_row:
470        return prev_sibling_same_row
471
472    # Leading comment: the next structure following the comment
473    current = node.next_named_sibling
474    while current:
475        if current.type in JSON_STRUCTURE_TYPES:
476            return current
477        current = current.next_named_sibling
478
479    # Otherwise: the enclosing structure
480    parent = node.parent
481    while parent:
482        if parent.type in {"pair", "object", "array"}:
483            return parent
484        parent = parent.parent
485
486    return None
487
488
489def find_associated_scope(
490    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
491) -> TreeSitterNode | None:
492    """Find the associated scope of a comment."""
493    if comment_type == CommentType.yaml:
494        # YAML uses different structure association logic
495        return find_yaml_associated_structure(node)
496
497    if comment_type == CommentType.jsonc:
498        # JSONC uses data-aware structure association logic
499        return find_jsonc_associated_structure(node)
500
501    if node.type == CommentCategory.docstring:
502        # Only for python's docstring
503        return find_enclosing_scope(node, comment_type)
504    # General comments regardless of comment types
505    associated_scope = find_next_scope(node, comment_type)
506    if not associated_scope:
507        associated_scope = find_enclosing_scope(node, comment_type)
508    return associated_scope
509
510
511def _git_dir(path: Path) -> Path | None:
512    """The git directory for a checkout rooted at ``path``, or None if it is not one.
513
514    In a plain checkout ``.git`` is a directory. In a LINKED WORKTREE it is a file
515    holding ``gitdir: <path>`` -- git's documented gitfile format -- and the real
516    directory is elsewhere, under the main repository's ``.git/worktrees/<name>``.
517    Treating the file as "not a repository" is what made every worktree of this
518    repository look unversioned (useblocks/sphinx-codelinks#106).
519    """
520    dot_git = path / ".git"
521    if dot_git.is_dir():
522        return dot_git
523    if dot_git.is_file():
524        content = dot_git.read_text(encoding="utf-8", errors="replace").strip()
525        if content.startswith("gitdir:"):
526            target = Path(content.split(":", 1)[1].strip())
527            if not target.is_absolute():
528                target = (path / target).resolve()
529            if target.is_dir():
530                return target
531    return None
532
533
534def _git_common_dir(git_dir: Path) -> Path:
535    """The repository-wide git directory that ``git_dir`` belongs to.
536
537    A linked worktree's git directory carries a ``commondir`` file naming the main
538    repository's, and that is where ``config`` and every shared ref live -- only
539    per-worktree state (``HEAD``, ``refs/bisect``, ``refs/worktree``) is local. A plain
540    checkout has no ``commondir`` and the two are the same directory.
541    """
542    commondir = git_dir / "commondir"
543    if not commondir.is_file():
544        return git_dir
545    target = Path(commondir.read_text(encoding="utf-8").strip())
546    if not target.is_absolute():
547        target = (git_dir / target).resolve()
548    return target
549
550
551def find_git_root(src_dir: Path) -> Path | None:
552    """The git root :func:`locate_git_root` finds, without its warning when none."""
553    current = src_dir.resolve()
554    parents = list(current.parents)
555    parents.append(current)
556    for parent in parents:
557        if _git_dir(parent) is not None:
558            return parent
559    return None
560
561
562def locate_git_root(src_dir: Path) -> Path | None:
563    """Traverse upwards to find git root."""
564    found = find_git_root(src_dir)
565    if found is not None:
566        return found
567    logger.warning(
568        f"git root is not found in the parent of {src_dir}",
569        subtype="git_root",
570        location=str(src_dir),
571    )
572    return None
573
574
575def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None:
576    """Get remote url from the git directory's config."""
577    git_dir = _git_dir(git_root)
578    # a worktree's remotes are the main repository's, so `config` is in the common dir
579    config_path = (
580        _git_common_dir(git_dir) if git_dir is not None else git_root / ".git"
581    ) / "config"
582    if not config_path.exists():
583        logger.warning(
584            f"{config_path} does not exist",
585            subtype="git_config",
586            location=str(config_path),
587        )
588        return None
589
590    config = configparser.ConfigParser(allow_no_value=True, strict=False)
591    config.read(config_path)
592    section = f'remote "{remote_name}"'
593    if section in config and "url" in config[section]:
594        url: str = config[section]["url"]
595        return url
596    logger.warning(
597        f"remote-url is not found in {config_path}",
598        subtype="git_remote",
599        location=str(config_path),
600    )
601    return None
602
603
604def _packed_ref(common_dir: Path, ref: str) -> str | None:
605    """The sha ``packed-refs`` records for ``ref``, or None.
606
607    A ref has no file of its own once ``git gc`` (or ``git pack-refs``) has run, which
608    happens unattended on any long-lived checkout -- so a loose-file lookup alone reports
609    "no rev" for a perfectly ordinary repository. The file is one ``<sha> <refname>`` per
610    line; ``^<sha>`` lines are the peeled targets of annotated tags and are skipped, as
611    is the leading ``#`` header.
612    """
613    packed = common_dir / "packed-refs"
614    if not packed.is_file():
615        return None
616    for line in packed.read_text(encoding="utf-8", errors="replace").splitlines():
617        if not line or line.startswith(("#", "^")):
618            continue
619        sha, _, name = line.partition(" ")
620        if name.strip() == ref:
621            return sha
622    return None
623
624
625def get_current_rev(git_root: Path) -> str | None:
626    """Get current commit rev from the git directory's HEAD."""
627    # HEAD is per-worktree, so it is read from the worktree's OWN git directory
628    git_dir = _git_dir(git_root) or git_root / ".git"
629    head_path = git_dir / "HEAD"
630    if not head_path.exists():
631        logger.warning(
632            f"{head_path} does not exist",
633            subtype="git_head",
634            location=str(head_path),
635        )
636        return None
637    head_content = head_path.read_text().strip()
638    if not head_content.startswith("ref: "):
639        # Detached HEAD (e.g. CI checkouts): .git/HEAD holds the commit SHA
640        # directly, which is exactly the rev we want.
641        return head_content
642
643    ref = head_content.split(":", 1)[1].strip()
644    common_dir = _git_common_dir(git_dir)
645    ref_path = git_dir / ref
646    if not ref_path.exists():
647        # every branch is a SHARED ref, so in a worktree it lives in the common
648        # directory; only refs/bisect and refs/worktree are per-worktree
649        ref_path = common_dir / ref
650    if ref_path.exists():
651        return ref_path.read_text().strip()
652    packed = _packed_ref(common_dir, ref)
653    if packed is not None:
654        return packed
655    logger.warning(
656        f"{ref_path} does not exist",
657        subtype="git_ref",
658        location=str(ref_path),
659    )
660    return None
661
662
663def form_https_url(
664    git_url: str, rev: str, project_path: Path, filepath: Path, lineno: int
665) -> str | None:
666    parsed_url = parse(git_url)
667    template = GIT_HOST_URL_TEMPLATE.get(parsed_url.platform)
668    if not template:
669        logger.warning(
670            f"Unsupported Git host: {parsed_url.platform}",
671            subtype="git_host",
672        )
673        return git_url
674    https_url = template.format(
675        owner=parsed_url.owner,
676        repo=parsed_url.repo,
677        rev=rev,
678        path=pathname2url(str(filepath.absolute().relative_to(project_path))),
679        lineno=str(lineno),
680    )
681    return https_url