Skip to content

Commit 4d705c9

Browse files
committed
build(bootstrap): ensure git symlinks
1 parent 4b79ef4 commit 4d705c9

File tree

1 file changed

+154
-1
lines changed

1 file changed

+154
-1
lines changed

bootstrap.py

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ def clone_repo(self, repo, dest, branch=None, depth=None):
433433
:param depth: The depth of the clone. Defaults to 1 (shallow clone).
434434
:return: None
435435
"""
436-
cmd = [self.options.git_path, "clone"]
436+
cmd = [self.options.git_path, "-c", "core.symlinks=true", "clone"]
437437
if branch:
438438
cmd.extend(["--branch", branch])
439439
if depth:
@@ -1460,7 +1460,160 @@ def create_cmake_presets(self):
14601460
with open(user_presets_path, "w") as f:
14611461
json.dump(user_presets, f, indent=4)
14621462

1463+
def _git_symlink_entries(self, repo_dir):
1464+
"""
1465+
Returns a list of (worktree_path, intended_target_string) for all git-tracked symlinks (mode 120000).
1466+
"""
1467+
out = subprocess.check_output(
1468+
[self.options.git_path, "-C", repo_dir, "ls-files", "-s"],
1469+
text=True, encoding="utf-8", errors="replace"
1470+
)
1471+
entries = []
1472+
for line in out.splitlines():
1473+
# "<mode> <object> <stage>\t<path>"
1474+
# Example for symlink: "120000 e69de29... 0\tpath/to/link"
1475+
try:
1476+
head, path = line.split("\t", 1)
1477+
mode, obj, _stage = head.split()[:3]
1478+
except ValueError:
1479+
continue
1480+
if mode != "120000":
1481+
continue
1482+
target = subprocess.check_output(
1483+
[self.options.git_path, "-C", repo_dir, "cat-file", "-p", obj],
1484+
text=True, encoding="utf-8", errors="replace"
1485+
).rstrip("\n")
1486+
entries.append((path, target))
1487+
return entries
1488+
1489+
def _same_link_target(self, link_path, intended):
1490+
"""Return True if link_path is a symlink pointing to intended (normalized)."""
1491+
try:
1492+
current = os.readlink(link_path)
1493+
except OSError:
1494+
return False
1495+
1496+
def norm(p):
1497+
return os.path.normpath(p.replace("/", os.sep))
1498+
1499+
return norm(current) == norm(intended)
1500+
1501+
def _make_symlink_or_fallback(self, file_path, intended_target, repo_dir):
1502+
"""
1503+
Create a symlink at file_path pointing to intended_target (POSIX path from git).
1504+
Falls back to hardlink/copy on Windows if symlinks aren’t permitted.
1505+
Returns: 'symlink' | 'hardlink' | 'copy'
1506+
"""
1507+
parent = os.path.dirname(file_path)
1508+
if parent and not os.path.isdir(parent):
1509+
os.makedirs(parent, exist_ok=True)
1510+
1511+
# Remove existing non-symlink file
1512+
if os.path.exists(file_path) and not os.path.islink(file_path):
1513+
os.remove(file_path)
1514+
1515+
# Git stores POSIX-style link text; translate to native separators for the OS call
1516+
native_target = intended_target.replace("/", os.sep)
1517+
1518+
# Detect if the final target (as it would resolve in the WT) is a directory (Windows needs this)
1519+
resolved_target = os.path.normpath(os.path.join(parent, native_target))
1520+
target_is_dir = os.path.isdir(resolved_target)
1521+
1522+
# Try real symlink first
1523+
try:
1524+
# On Windows, target_is_directory must be correct for directory links
1525+
if os.name == "nt":
1526+
os.symlink(native_target, file_path, target_is_directory=target_is_dir)
1527+
else:
1528+
os.symlink(native_target, file_path)
1529+
return "symlink"
1530+
except (NotImplementedError, OSError, PermissionError):
1531+
pass
1532+
1533+
# Fallback: hardlink (files only, same volume)
1534+
try:
1535+
if os.path.isfile(resolved_target):
1536+
os.link(resolved_target, file_path)
1537+
return "hardlink"
1538+
except OSError:
1539+
pass
1540+
1541+
# Last resort: copy the file contents if it exists
1542+
if os.path.isfile(resolved_target):
1543+
shutil.copyfile(resolved_target, file_path)
1544+
return "copy"
1545+
1546+
# If the target doesn’t exist in WT, write the intended link text so state is explicit
1547+
with open(file_path, "w", encoding="utf-8") as f:
1548+
f.write(intended_target)
1549+
return "copy"
1550+
1551+
def _is_git_repo(self, repo_dir):
1552+
"""Return True if repo_dir looks like a Git work tree."""
1553+
if os.path.isdir(os.path.join(repo_dir, ".git")):
1554+
return True
1555+
try:
1556+
out = subprocess.check_output(
1557+
[self.options.git_path, "-C", repo_dir, "rev-parse", "--is-inside-work-tree"],
1558+
stderr=subprocess.DEVNULL, text=True
1559+
)
1560+
return out.strip() == "true"
1561+
except Exception:
1562+
return False
1563+
1564+
def check_git_symlinks(self, repo_dir):
1565+
"""
1566+
Ensure all Git-tracked symlinks in repo_dir are correct in the working tree.
1567+
Fixes text-file placeholders produced when core.symlinks=false.
1568+
"""
1569+
repo_dir = os.path.abspath(repo_dir)
1570+
if not self._is_git_repo(repo_dir):
1571+
return
1572+
1573+
symlinks = self._git_symlink_entries(repo_dir)
1574+
if not symlinks:
1575+
return
1576+
1577+
fixed = {"symlink": 0, "hardlink": 0, "copy": 0, "already_ok": 0}
1578+
1579+
for rel_path, intended in symlinks:
1580+
link_path = os.path.join(repo_dir, rel_path)
1581+
1582+
# Already OK?
1583+
if os.path.islink(link_path) and self._same_link_target(link_path, intended):
1584+
fixed["already_ok"] += 1
1585+
continue
1586+
1587+
# If it's a regular file that merely contains the target text, replace it anyway
1588+
if os.path.exists(link_path) and not os.path.islink(link_path):
1589+
try:
1590+
with open(link_path, "r", encoding="utf-8") as f:
1591+
content = f.read().strip()
1592+
# no-op: we still replace below if content == intended (or even if not)
1593+
except Exception:
1594+
# unreadable is fine; we’ll still replace
1595+
pass
1596+
1597+
kind = self._make_symlink_or_fallback(link_path, intended, repo_dir)
1598+
fixed[kind] += 1
1599+
1600+
# Summary + Windows hint
1601+
if (fixed["symlink"] + fixed["hardlink"] + fixed["copy"]) > 0:
1602+
print(
1603+
f"Repaired Git symlinks in {repo_dir} "
1604+
f"(created: {fixed['symlink']} symlink(s), {fixed['hardlink']} hardlink(s), "
1605+
f"{fixed['copy']} copy/copies; {fixed['already_ok']} already OK)."
1606+
)
1607+
if fixed["hardlink"] or fixed["copy"]:
1608+
print(
1609+
"Warning: Some symlinks could not be created. On Windows, enable Developer Mode "
1610+
"or run with privileges that allow creating symlinks. Also ensure "
1611+
"`git config core.symlinks true` before checkout."
1612+
)
1613+
14631614
def install_mrdocs(self):
1615+
self.check_git_symlinks(self.options.mrdocs_src_dir)
1616+
14641617
if not self.options.mrdocs_use_user_presets:
14651618
self.prompt_option("mrdocs_build_dir")
14661619
else:

0 commit comments

Comments
 (0)