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