xt-cli 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- __init__.py +5 -0
- __main__.py +7 -0
- cli.py +342 -0
- commands/build_cmd.py +67 -0
- commands/clean_cmd.py +31 -0
- commands/config_cmd.py +202 -0
- commands/deps_cmd.py +128 -0
- commands/fullclean_cmd.py +32 -0
- config.py +356 -0
- constants.py +6 -0
- context.py +238 -0
- dependencies.py +1109 -0
- errors.py +29 -0
- hooks.py +317 -0
- models.py +107 -0
- output.py +16 -0
- paths.py +61 -0
- project.py +28 -0
- xmake.py +92 -0
- xt_cli-0.2.1.dist-info/METADATA +125 -0
- xt_cli-0.2.1.dist-info/RECORD +26 -0
- xt_cli-0.2.1.dist-info/WHEEL +5 -0
- xt_cli-0.2.1.dist-info/entry_points.txt +2 -0
- xt_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
- xt_cli-0.2.1.dist-info/top_level.txt +16 -0
- xt_cli.py +10 -0
dependencies.py
ADDED
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
"""工程版本追踪与管理。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import fnmatch
|
|
6
|
+
import json
|
|
7
|
+
import os as _os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import json5
|
|
15
|
+
|
|
16
|
+
from config import ConfigStore
|
|
17
|
+
from paths import build_global_config_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_git_version(repo_path: Path) -> tuple[str, bool]:
|
|
21
|
+
"""获取 git 仓库的 HEAD commit sha(8 位)和 dirty 状态。
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
(sha, is_dirty): sha 为 8 位短哈希,is_dirty 表示是否有未提交改动。
|
|
25
|
+
Raises:
|
|
26
|
+
subprocess.CalledProcessError: 非 git 仓库或 git 命令失败。
|
|
27
|
+
"""
|
|
28
|
+
sha = subprocess.run(
|
|
29
|
+
["git", "-C", str(repo_path), "rev-parse", "--short=8", "HEAD"],
|
|
30
|
+
check=True, capture_output=True, text=True, encoding="utf-8",
|
|
31
|
+
).stdout.strip()
|
|
32
|
+
dirty_output = subprocess.run(
|
|
33
|
+
["git", "-C", str(repo_path), "status", "--porcelain"],
|
|
34
|
+
check=True, capture_output=True, text=True, encoding="utf-8",
|
|
35
|
+
).stdout.strip()
|
|
36
|
+
return sha, bool(dirty_output)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_git_repo(path: Path) -> bool:
|
|
40
|
+
"""检查目录是否是 git 仓库。"""
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["git", "-C", str(path), "rev-parse", "--git-dir"],
|
|
43
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
44
|
+
)
|
|
45
|
+
return result.returncode == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_ancestor(older: str, newer: str, repo_path: Path) -> bool:
|
|
49
|
+
"""检查 older 是否是 newer 的祖先提交(通过 merge-base)。"""
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
["git", "-C", str(repo_path), "merge-base", "--is-ancestor", older, newer],
|
|
52
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
53
|
+
)
|
|
54
|
+
return result.returncode == 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _check_sha_constraint(
|
|
58
|
+
op: str, required: str, current: str, repo_path: Path
|
|
59
|
+
) -> bool:
|
|
60
|
+
"""检查当前 sha 是否满足单个约束。
|
|
61
|
+
|
|
62
|
+
操作符映射:
|
|
63
|
+
- == : 精确匹配(前 8 位)
|
|
64
|
+
- != : 不等于
|
|
65
|
+
- >= : current 是 required 的后代(含 required 的改动)
|
|
66
|
+
- <= : current 是 required 的祖先
|
|
67
|
+
- > : >= 且 !=
|
|
68
|
+
- < : <= 且 !=
|
|
69
|
+
"""
|
|
70
|
+
if op == "==":
|
|
71
|
+
return current == required[:len(current)]
|
|
72
|
+
if op == "!=":
|
|
73
|
+
return current != required[:len(current)]
|
|
74
|
+
if op == ">=":
|
|
75
|
+
return _check_ancestor(required, "HEAD", repo_path)
|
|
76
|
+
if op == "<=":
|
|
77
|
+
return _check_ancestor("HEAD", required, repo_path)
|
|
78
|
+
if op == ">":
|
|
79
|
+
return _check_ancestor(required, "HEAD", repo_path) and current != required[:len(current)]
|
|
80
|
+
if op == "<":
|
|
81
|
+
return _check_ancestor("HEAD", required, repo_path) and current != required[:len(current)]
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_semver(ver: str) -> tuple[int, int, int, str] | None:
|
|
86
|
+
"""解析 semver 版本号。支持 v1.2.3, 1.2.3-alpha, v1.2.3-beta.1。"""
|
|
87
|
+
v = ver.strip()
|
|
88
|
+
if v.startswith("v"):
|
|
89
|
+
v = v[1:]
|
|
90
|
+
m = re.match(r'^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$', v)
|
|
91
|
+
if not m:
|
|
92
|
+
return None
|
|
93
|
+
return int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _semver_cmp(a: tuple[int, int, int, str], b: tuple[int, int, int, str]) -> int:
|
|
97
|
+
"""比较两个 semver,返回 -1/0/1。"""
|
|
98
|
+
# 数值部分
|
|
99
|
+
for i in range(3):
|
|
100
|
+
if a[i] < b[i]:
|
|
101
|
+
return -1
|
|
102
|
+
if a[i] > b[i]:
|
|
103
|
+
return 1
|
|
104
|
+
# prerelease 部分:有 prerelease < 无 prerelease
|
|
105
|
+
if a[3] == b[3]:
|
|
106
|
+
return 0
|
|
107
|
+
if not a[3]:
|
|
108
|
+
return 1
|
|
109
|
+
if not b[3]:
|
|
110
|
+
return -1
|
|
111
|
+
return -1 if a[3] < b[3] else (1 if a[3] > b[3] else 0)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _check_semver_constraint(op: str, required_str: str, current_str: str) -> bool:
|
|
115
|
+
"""检查当前 semver 是否满足约束。"""
|
|
116
|
+
required = _parse_semver(required_str)
|
|
117
|
+
current = _parse_semver(current_str)
|
|
118
|
+
if required is None or current is None:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
if op == "==":
|
|
122
|
+
return _semver_cmp(current, required) == 0
|
|
123
|
+
if op == "!=":
|
|
124
|
+
return _semver_cmp(current, required) != 0
|
|
125
|
+
if op == ">":
|
|
126
|
+
return _semver_cmp(current, required) > 0
|
|
127
|
+
if op == ">=":
|
|
128
|
+
return _semver_cmp(current, required) >= 0
|
|
129
|
+
if op == "<":
|
|
130
|
+
return _semver_cmp(current, required) < 0
|
|
131
|
+
if op == "<=":
|
|
132
|
+
return _semver_cmp(current, required) <= 0
|
|
133
|
+
if op == "~":
|
|
134
|
+
upper = (required[0], required[1] + 1, 0, "")
|
|
135
|
+
return _semver_cmp(current, required) >= 0 and _semver_cmp(current, upper) < 0
|
|
136
|
+
if op == "^":
|
|
137
|
+
if required[0] == 0:
|
|
138
|
+
return _check_semver_constraint("~", required_str, current_str)
|
|
139
|
+
upper = (required[0] + 1, 0, 0, "")
|
|
140
|
+
return _semver_cmp(current, required) >= 0 and _semver_cmp(current, upper) < 0
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _get_semver_version(repo_path: Path) -> str | None:
|
|
145
|
+
"""从 git tag 获取当前 semver 版本。"""
|
|
146
|
+
try:
|
|
147
|
+
tags = subprocess.run(
|
|
148
|
+
["git", "-C", str(repo_path), "tag", "--points-at", "HEAD"],
|
|
149
|
+
check=True, capture_output=True, text=True, encoding="utf-8",
|
|
150
|
+
).stdout.strip().splitlines()
|
|
151
|
+
tags = [t.strip() for t in tags if t.strip()]
|
|
152
|
+
if tags:
|
|
153
|
+
best = None
|
|
154
|
+
best_parsed = None
|
|
155
|
+
for t in tags:
|
|
156
|
+
p = _parse_semver(t)
|
|
157
|
+
if p and (best_parsed is None or _semver_cmp(p, best_parsed) > 0):
|
|
158
|
+
best = t
|
|
159
|
+
best_parsed = p
|
|
160
|
+
if best:
|
|
161
|
+
s = best
|
|
162
|
+
return s[1:] if s.startswith("v") else s
|
|
163
|
+
except subprocess.CalledProcessError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
desc = subprocess.run(
|
|
168
|
+
["git", "-C", str(repo_path), "describe", "--tags", "--abbrev=0"],
|
|
169
|
+
check=True, capture_output=True, text=True, encoding="utf-8",
|
|
170
|
+
).stdout.strip()
|
|
171
|
+
p = _parse_semver(desc)
|
|
172
|
+
if p:
|
|
173
|
+
s = desc
|
|
174
|
+
return s[1:] if s.startswith("v") else s
|
|
175
|
+
except subprocess.CalledProcessError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _load_jsonc(path: Path) -> dict[str, Any] | None:
|
|
182
|
+
"""加载 JSONC 文件,不存在返回 None。"""
|
|
183
|
+
if not path.is_file():
|
|
184
|
+
return None
|
|
185
|
+
try:
|
|
186
|
+
raw = path.read_text(encoding="utf-8")
|
|
187
|
+
data = json5.loads(raw)
|
|
188
|
+
except (ValueError, json.JSONDecodeError):
|
|
189
|
+
return None
|
|
190
|
+
if not isinstance(data, dict):
|
|
191
|
+
return None
|
|
192
|
+
return data
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _save_jsonc(path: Path, data: dict[str, Any]) -> bool:
|
|
196
|
+
"""保存 JSONC 文件(机器生成用 JSON,无注释)。
|
|
197
|
+
|
|
198
|
+
与现有内容比较,无变化不写。返回 True 表示已写入。
|
|
199
|
+
"""
|
|
200
|
+
new_content = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
201
|
+
if path.is_file() and path.read_text(encoding="utf-8") == new_content:
|
|
202
|
+
return False
|
|
203
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
path.write_text(new_content, encoding="utf-8")
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _identify_val_type(val: str) -> str | None:
|
|
209
|
+
"""识别 val 的类型。
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
"git" | "semver" | None
|
|
213
|
+
"""
|
|
214
|
+
if re.fullmatch(r'[0-9a-fA-F]{7,40}', val):
|
|
215
|
+
return "git"
|
|
216
|
+
if re.search(r'\d', val) and '.' in val:
|
|
217
|
+
return "semver"
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _parse_require(val: str) -> list[tuple[str, str]]:
|
|
222
|
+
"""解析 require 字符串为 (操作符, 版本) 列表。
|
|
223
|
+
|
|
224
|
+
"b8d82d2" → [("==", "b8d82d2")]
|
|
225
|
+
">= c9e1234, != b8d82d2" → [(">=", "c9e1234"), ("!=", "b8d82d2")]
|
|
226
|
+
"""
|
|
227
|
+
constraints: list[tuple[str, str]] = []
|
|
228
|
+
for part in val.split(','):
|
|
229
|
+
part = part.strip()
|
|
230
|
+
if not part:
|
|
231
|
+
continue
|
|
232
|
+
match = re.match(r'^(>=|<=|==|!=|~|>|<)?\s*(.+)$', part)
|
|
233
|
+
if match:
|
|
234
|
+
op = match.group(1) or "=="
|
|
235
|
+
version = match.group(2).strip()
|
|
236
|
+
constraints.append((op, version))
|
|
237
|
+
return constraints
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _print_warning(title: str, body: list[str], hint: str = "") -> None:
|
|
242
|
+
"""输出 ANSI 黄色警告框。
|
|
243
|
+
|
|
244
|
+
body 行缩进两格,hint 灰色显示在末尾。
|
|
245
|
+
格式(设计文档 §6.2):
|
|
246
|
+
────────────────────────── WARNING ──────────────────────────
|
|
247
|
+
title
|
|
248
|
+
body_line_1
|
|
249
|
+
body_line_2
|
|
250
|
+
hint
|
|
251
|
+
────────────────────────────────────────────────────────────
|
|
252
|
+
"""
|
|
253
|
+
YELLOW = "\033[33m"
|
|
254
|
+
BOLD_YELLOW = "\033[1;33m"
|
|
255
|
+
GRAY = "\033[90m"
|
|
256
|
+
RESET = "\033[0m"
|
|
257
|
+
SEP = "─" * 60
|
|
258
|
+
|
|
259
|
+
print(f"{YELLOW}{SEP[:26]} {BOLD_YELLOW}WARNING{RESET}{YELLOW} {SEP[:26]}{RESET}")
|
|
260
|
+
print(f"{YELLOW}{title}{RESET}")
|
|
261
|
+
for line in body:
|
|
262
|
+
print(f"{YELLOW} {line}{RESET}")
|
|
263
|
+
if hint:
|
|
264
|
+
print(f"{GRAY}{hint}{RESET}")
|
|
265
|
+
print(f"{YELLOW}{SEP}{RESET}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _print_error(msg: str) -> None:
|
|
269
|
+
"""输出错误信息到 stderr。"""
|
|
270
|
+
print(f"\033[31mError: {msg}\033[0m", file=sys.stderr)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _print_info(msg: str) -> None:
|
|
274
|
+
"""输出信息到 stdout。"""
|
|
275
|
+
print(f"Info: {msg}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _print_warnings_box(warnings: list[tuple[str, list[str]]]) -> None:
|
|
279
|
+
"""输出所有警告到一个 ANSI 黄色警告框中。
|
|
280
|
+
|
|
281
|
+
格式:头尾各一条分隔线,中间每条警告标题 + 缩进 body。
|
|
282
|
+
"""
|
|
283
|
+
YELLOW = "\033[33m"
|
|
284
|
+
BOLD_YELLOW = "\033[1;33m"
|
|
285
|
+
RESET = "\033[0m"
|
|
286
|
+
SEP = "─" * 60
|
|
287
|
+
|
|
288
|
+
print(f"{YELLOW}{SEP[:26]} {BOLD_YELLOW}WARNING{RESET}{YELLOW} {SEP[:26]}{RESET}")
|
|
289
|
+
for i, (title, body) in enumerate(warnings):
|
|
290
|
+
if i > 0:
|
|
291
|
+
print() # 条目间空行
|
|
292
|
+
print(f"{YELLOW}{title}{RESET}")
|
|
293
|
+
for line in body:
|
|
294
|
+
print(f"{YELLOW} {line}{RESET}")
|
|
295
|
+
print(f"{YELLOW}{SEP}{RESET}")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _relative_path(target: Path, base: Path) -> str:
|
|
299
|
+
"""计算相对路径。
|
|
300
|
+
|
|
301
|
+
同级或子目录 → ./相对路径
|
|
302
|
+
父目录 → ../相对路径
|
|
303
|
+
跨盘符或无法相对化 → 返回原绝对路径
|
|
304
|
+
"""
|
|
305
|
+
try:
|
|
306
|
+
rel = target.resolve().relative_to(base.resolve())
|
|
307
|
+
return "./" + rel.as_posix()
|
|
308
|
+
except ValueError:
|
|
309
|
+
try:
|
|
310
|
+
return _os.path.relpath(target, base).replace('\\', '/')
|
|
311
|
+
except ValueError:
|
|
312
|
+
return target.as_posix()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ============================================================
|
|
316
|
+
# Task 6: path resolver, on_fail resolver, targets resolver
|
|
317
|
+
# ============================================================
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _resolve_repo_path(name: str, deps_entry: dict, context) -> Path | None:
|
|
321
|
+
"""解析仓库路径。
|
|
322
|
+
|
|
323
|
+
优先级:xt_deps 中的 path 字段 > 默认路径。
|
|
324
|
+
xt-sdk → context.sdk_dir
|
|
325
|
+
platforms.<name> → context.sdk_dir / "platforms" / <name>
|
|
326
|
+
"""
|
|
327
|
+
if name == "xt-sdk":
|
|
328
|
+
return context.sdk_dir
|
|
329
|
+
custom_path = deps_entry.get("path")
|
|
330
|
+
if custom_path:
|
|
331
|
+
return context.project_dir / custom_path
|
|
332
|
+
default_path = context.sdk_dir / "platforms" / name
|
|
333
|
+
if default_path.is_dir():
|
|
334
|
+
return default_path
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _resolve_on_fail(deps_entry: dict, scope: str, context) -> str:
|
|
339
|
+
"""解析 on_fail 值。
|
|
340
|
+
|
|
341
|
+
优先级:条目显式值 > xt_deps.jsonc deps 段 > local config > global config > 默认 "warn"。
|
|
342
|
+
scope: "version" 读 deps.on_fail, "target" 读 deps.targets.on_fail。
|
|
343
|
+
"""
|
|
344
|
+
if scope == "version":
|
|
345
|
+
explicit = deps_entry.get("on_fail")
|
|
346
|
+
else:
|
|
347
|
+
targets = deps_entry.get("targets")
|
|
348
|
+
explicit = targets.get("on_fail") if isinstance(targets, dict) else None
|
|
349
|
+
|
|
350
|
+
if explicit in ("warn", "error", "ignore"):
|
|
351
|
+
return explicit
|
|
352
|
+
|
|
353
|
+
config_key = f"on_{scope}_fail"
|
|
354
|
+
|
|
355
|
+
# xt_deps.jsonc deps_configs 段
|
|
356
|
+
xt_deps = _load_jsonc(context.project_dir / "xt_deps.jsonc")
|
|
357
|
+
if xt_deps:
|
|
358
|
+
deps_block = xt_deps.get("deps_configs")
|
|
359
|
+
if isinstance(deps_block, dict):
|
|
360
|
+
value = deps_block.get(config_key)
|
|
361
|
+
if value in ("warn", "error", "ignore"):
|
|
362
|
+
return value
|
|
363
|
+
|
|
364
|
+
# local config
|
|
365
|
+
local_path = context.project_dir / "xt_conf.jsonc"
|
|
366
|
+
if local_path.is_file():
|
|
367
|
+
local_data = ConfigStore(local_path).load()
|
|
368
|
+
deps = local_data.get("deps_configs")
|
|
369
|
+
if isinstance(deps, dict):
|
|
370
|
+
value = deps.get(config_key)
|
|
371
|
+
if value in ("warn", "error", "ignore"):
|
|
372
|
+
return value
|
|
373
|
+
|
|
374
|
+
# global config
|
|
375
|
+
global_path = build_global_config_path()
|
|
376
|
+
if global_path.is_file():
|
|
377
|
+
global_data = ConfigStore(global_path).load()
|
|
378
|
+
deps = global_data.get("deps_configs")
|
|
379
|
+
if isinstance(deps, dict):
|
|
380
|
+
value = deps.get(config_key)
|
|
381
|
+
if value in ("warn", "error", "ignore"):
|
|
382
|
+
return value
|
|
383
|
+
|
|
384
|
+
return "warn"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _resolve_targets_constraint(deps_entry: dict) -> list[str] | None:
|
|
388
|
+
"""从 deps 条目中提取 targets 约束列表。
|
|
389
|
+
|
|
390
|
+
支持简写(数组)和完整对象两种格式。
|
|
391
|
+
返回 None 表示无约束。
|
|
392
|
+
"""
|
|
393
|
+
targets = deps_entry.get("targets")
|
|
394
|
+
if targets is None:
|
|
395
|
+
return None
|
|
396
|
+
if isinstance(targets, list):
|
|
397
|
+
return targets
|
|
398
|
+
if isinstance(targets, dict):
|
|
399
|
+
require = targets.get("require")
|
|
400
|
+
if isinstance(require, list):
|
|
401
|
+
return require
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def sync_dependencies(context, dirty_allowed: bool = False) -> None:
|
|
406
|
+
"""根据 xt_deps.jsonc 将各仓库 checkout 到要求的版本。
|
|
407
|
+
|
|
408
|
+
在 check_dependencies 之前调用。dirty 仓库默认中止(--dirty 允许)。
|
|
409
|
+
"""
|
|
410
|
+
xt_deps = _load_jsonc(context.project_dir / "xt_deps.jsonc")
|
|
411
|
+
if xt_deps is None:
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
print("[xt cli] Syncing dependencies...")
|
|
415
|
+
|
|
416
|
+
# xt-sdk
|
|
417
|
+
xt_sdk_entry = xt_deps.get("xt-sdk")
|
|
418
|
+
if isinstance(xt_sdk_entry, dict):
|
|
419
|
+
_sync_repo("xt-sdk", context.sdk_dir, xt_sdk_entry, dirty_allowed)
|
|
420
|
+
|
|
421
|
+
# platforms
|
|
422
|
+
platforms_deps = xt_deps.get("platforms", {})
|
|
423
|
+
if isinstance(platforms_deps, dict):
|
|
424
|
+
for name, entry in platforms_deps.items():
|
|
425
|
+
if not isinstance(entry, dict):
|
|
426
|
+
continue
|
|
427
|
+
repo_path = _resolve_repo_path(name, entry, context)
|
|
428
|
+
if repo_path and repo_path.is_dir():
|
|
429
|
+
_sync_repo(name, repo_path, entry, dirty_allowed)
|
|
430
|
+
|
|
431
|
+
# components
|
|
432
|
+
components_deps = xt_deps.get("components", {})
|
|
433
|
+
if isinstance(components_deps, dict):
|
|
434
|
+
for name, entry in components_deps.items():
|
|
435
|
+
if not isinstance(entry, dict):
|
|
436
|
+
continue
|
|
437
|
+
repo_path = _resolve_component_path(name, entry, context)
|
|
438
|
+
if repo_path and repo_path.is_dir():
|
|
439
|
+
_sync_repo(name, repo_path, entry, dirty_allowed)
|
|
440
|
+
|
|
441
|
+
print("[xt cli] Sync complete.")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _sync_repo(name: str, repo_path, entry: dict, dirty_allowed: bool) -> None:
|
|
445
|
+
"""同步单个仓库到 require 指定的版本。"""
|
|
446
|
+
require = entry.get("require")
|
|
447
|
+
if require is None:
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
# 检查 dirty
|
|
451
|
+
if (repo_path / ".git").exists():
|
|
452
|
+
try:
|
|
453
|
+
_, is_dirty = _get_git_version(repo_path)
|
|
454
|
+
if is_dirty and not dirty_allowed:
|
|
455
|
+
_print_error(f"{name}: repository is dirty, aborting sync. Use --dirty to allow.")
|
|
456
|
+
sys.exit(1)
|
|
457
|
+
except subprocess.CalledProcessError:
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
# 确定目标版本
|
|
461
|
+
if isinstance(require, dict):
|
|
462
|
+
val_type = require.get("type")
|
|
463
|
+
target = require.get("val", "")
|
|
464
|
+
if val_type == "hash":
|
|
465
|
+
print(f" {name}: hash type, skip checkout")
|
|
466
|
+
return
|
|
467
|
+
else:
|
|
468
|
+
target = require # 字符串:可能是 sha 或 semver
|
|
469
|
+
|
|
470
|
+
# semver → 找 tag
|
|
471
|
+
if (repo_path / ".git").exists() and _identify_val_type(target) == "semver":
|
|
472
|
+
# 当前已经是目标版本 → 跳过
|
|
473
|
+
current_ver = _get_semver_version(repo_path)
|
|
474
|
+
if current_ver:
|
|
475
|
+
target_parsed = _parse_semver(target)
|
|
476
|
+
current_parsed = _parse_semver(current_ver)
|
|
477
|
+
if target_parsed and current_parsed and _semver_cmp(current_parsed, target_parsed) == 0:
|
|
478
|
+
print(f" {name}: no change (already at {current_ver})")
|
|
479
|
+
return
|
|
480
|
+
# 尝试 checkout 对应 tag
|
|
481
|
+
for prefix in ("", "v", "V"):
|
|
482
|
+
tag = prefix + target
|
|
483
|
+
try:
|
|
484
|
+
subprocess.run(
|
|
485
|
+
["git", "-C", str(repo_path), "checkout", tag],
|
|
486
|
+
check=True, capture_output=True, text=True, encoding="utf-8",
|
|
487
|
+
)
|
|
488
|
+
print(f" {name}: checkout {tag}")
|
|
489
|
+
return
|
|
490
|
+
except subprocess.CalledProcessError:
|
|
491
|
+
continue
|
|
492
|
+
_print_error(f"{name}: cannot find tag for {target}")
|
|
493
|
+
sys.exit(1)
|
|
494
|
+
|
|
495
|
+
# git sha → checkout
|
|
496
|
+
if (repo_path / ".git").exists():
|
|
497
|
+
# 检查是否已经在目标版本
|
|
498
|
+
try:
|
|
499
|
+
current_sha = subprocess.run(
|
|
500
|
+
["git", "-C", str(repo_path), "rev-parse", "--short=8", "HEAD"],
|
|
501
|
+
check=True, capture_output=True, text=True, encoding="utf-8",
|
|
502
|
+
).stdout.strip()
|
|
503
|
+
if current_sha == target[:len(current_sha)]:
|
|
504
|
+
print(f" {name}: no change (already at {current_sha})")
|
|
505
|
+
return
|
|
506
|
+
except subprocess.CalledProcessError:
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
subprocess.run(
|
|
510
|
+
["git", "-C", str(repo_path), "checkout", target],
|
|
511
|
+
check=True, capture_output=True, text=True, encoding="utf-8",
|
|
512
|
+
)
|
|
513
|
+
print(f" {name}: checkout {target}")
|
|
514
|
+
|
|
515
|
+
# xt-sdk 额外同步 submodule
|
|
516
|
+
if name == "xt-sdk":
|
|
517
|
+
subprocess.run(
|
|
518
|
+
["git", "-C", str(repo_path), "submodule", "update", "--init", "--recursive"],
|
|
519
|
+
check=True, capture_output=True, text=True, encoding="utf-8",
|
|
520
|
+
)
|
|
521
|
+
else:
|
|
522
|
+
print(f" {name}: not a git repo, skip checkout")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ============================================================
|
|
526
|
+
# Task 6: check_dependencies 核心逻辑
|
|
527
|
+
# ============================================================
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _is_track_enabled(context) -> bool:
|
|
531
|
+
"""检查是否允许写入 xt_vers(默认 false,需显式开启)。
|
|
532
|
+
|
|
533
|
+
优先级:xt_deps.jsonc deps_configs.track > local config > global config > 默认 false。
|
|
534
|
+
"""
|
|
535
|
+
# xt_deps.jsonc deps_configs 段
|
|
536
|
+
xt_deps = _load_jsonc(context.project_dir / "xt_deps.jsonc")
|
|
537
|
+
if xt_deps:
|
|
538
|
+
deps_block = xt_deps.get("deps_configs")
|
|
539
|
+
if isinstance(deps_block, dict) and "track" in deps_block:
|
|
540
|
+
val = deps_block["track"]
|
|
541
|
+
if isinstance(val, bool):
|
|
542
|
+
return val
|
|
543
|
+
if isinstance(val, str):
|
|
544
|
+
return val.lower() in ("true", "1")
|
|
545
|
+
|
|
546
|
+
local_path = context.project_dir / "xt_conf.jsonc"
|
|
547
|
+
if local_path.is_file():
|
|
548
|
+
local_data = ConfigStore(local_path).load()
|
|
549
|
+
deps = local_data.get("deps_configs")
|
|
550
|
+
if isinstance(deps, dict) and "track" in deps:
|
|
551
|
+
val = deps["track"]
|
|
552
|
+
if isinstance(val, bool):
|
|
553
|
+
return val
|
|
554
|
+
if isinstance(val, str):
|
|
555
|
+
return val.lower() in ("true", "1")
|
|
556
|
+
return False
|
|
557
|
+
global_path = build_global_config_path()
|
|
558
|
+
if global_path.is_file():
|
|
559
|
+
global_data = ConfigStore(global_path).load()
|
|
560
|
+
deps = global_data.get("deps_configs")
|
|
561
|
+
if isinstance(deps, dict) and "track" in deps:
|
|
562
|
+
val = deps["track"]
|
|
563
|
+
if isinstance(val, bool):
|
|
564
|
+
return val
|
|
565
|
+
if isinstance(val, str):
|
|
566
|
+
return val.lower() in ("true", "1")
|
|
567
|
+
return False
|
|
568
|
+
return False
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def check_dependencies(context, dirty_allowed: bool = False, strict: bool = False) -> tuple[bool, list]:
|
|
572
|
+
"""before build: 读取 xt_deps,检查约束和 commit。
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
context: BuildContext
|
|
576
|
+
dirty_allowed: --dirty CLI 标志,True 时脏仓库仅警告不中止。
|
|
577
|
+
strict: --strict CLI 标志,True 时将 warn 升级为 error。
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
(passed, warnings): passed=False 时调用方应中止编译。
|
|
581
|
+
warnings 为 [(title, [body_lines])] 列表,由调用方在合适时机输出。
|
|
582
|
+
"""
|
|
583
|
+
xt_deps = _load_jsonc(context.project_dir / "xt_deps.jsonc")
|
|
584
|
+
if xt_deps is None:
|
|
585
|
+
return True, []
|
|
586
|
+
|
|
587
|
+
# --strict 时检查工程自身仓库是否干净
|
|
588
|
+
if strict and not dirty_allowed:
|
|
589
|
+
project_dir = context.project_dir
|
|
590
|
+
if (project_dir / ".git").exists():
|
|
591
|
+
try:
|
|
592
|
+
_, is_dirty = _get_git_version(project_dir)
|
|
593
|
+
if is_dirty:
|
|
594
|
+
_print_error(
|
|
595
|
+
"Project repository is dirty.\n"
|
|
596
|
+
f" Path: {project_dir}\n"
|
|
597
|
+
" Use --dirty to allow."
|
|
598
|
+
)
|
|
599
|
+
return False, []
|
|
600
|
+
except subprocess.CalledProcessError:
|
|
601
|
+
pass
|
|
602
|
+
|
|
603
|
+
xt_vers = _load_jsonc(context.project_dir / "xt_vers.jsonc")
|
|
604
|
+
release = xt_deps.get("release", False)
|
|
605
|
+
all_pass = True
|
|
606
|
+
warnings: list[tuple[str, list[str]]] = []
|
|
607
|
+
|
|
608
|
+
# 检查 xt-sdk
|
|
609
|
+
xt_sdk_entry = xt_deps.get("xt-sdk")
|
|
610
|
+
if isinstance(xt_sdk_entry, dict):
|
|
611
|
+
if not _check_entry(
|
|
612
|
+
"xt-sdk",
|
|
613
|
+
context.sdk_dir,
|
|
614
|
+
xt_sdk_entry,
|
|
615
|
+
xt_vers.get("xt-sdk") if xt_vers else None,
|
|
616
|
+
release=release,
|
|
617
|
+
dirty_allowed=dirty_allowed,
|
|
618
|
+
context=context,
|
|
619
|
+
check_targets=False,
|
|
620
|
+
warnings=warnings,
|
|
621
|
+
strict=strict,
|
|
622
|
+
):
|
|
623
|
+
all_pass = False
|
|
624
|
+
|
|
625
|
+
# 检查当前平台
|
|
626
|
+
platforms_deps = xt_deps.get("platforms", {})
|
|
627
|
+
if isinstance(platforms_deps, dict):
|
|
628
|
+
platform_name = context.platform_name
|
|
629
|
+
entry = platforms_deps.get(platform_name)
|
|
630
|
+
if isinstance(entry, dict):
|
|
631
|
+
repo_path = _resolve_repo_path(platform_name, entry, context)
|
|
632
|
+
if repo_path is None:
|
|
633
|
+
_print_error(f"{platform_name}: repository not found.")
|
|
634
|
+
all_pass = False
|
|
635
|
+
elif not repo_path.is_dir():
|
|
636
|
+
_print_error(f"{platform_name}: repository not found at '{repo_path}'")
|
|
637
|
+
all_pass = False
|
|
638
|
+
else:
|
|
639
|
+
platforms_vers = xt_vers.get("platforms", {}) if xt_vers else {}
|
|
640
|
+
if not _check_entry(
|
|
641
|
+
platform_name,
|
|
642
|
+
repo_path,
|
|
643
|
+
entry,
|
|
644
|
+
platforms_vers.get(platform_name),
|
|
645
|
+
release=release,
|
|
646
|
+
dirty_allowed=dirty_allowed,
|
|
647
|
+
context=context,
|
|
648
|
+
check_targets=True,
|
|
649
|
+
warnings=warnings,
|
|
650
|
+
strict=strict,
|
|
651
|
+
):
|
|
652
|
+
all_pass = False
|
|
653
|
+
|
|
654
|
+
# 检查 components
|
|
655
|
+
components_deps = xt_deps.get("components", {})
|
|
656
|
+
if isinstance(components_deps, dict):
|
|
657
|
+
components_vers = xt_vers.get("components", {}) if xt_vers else {}
|
|
658
|
+
for name, entry in components_deps.items():
|
|
659
|
+
if not isinstance(entry, dict):
|
|
660
|
+
continue
|
|
661
|
+
repo_path = _resolve_component_path(name, entry, context)
|
|
662
|
+
if repo_path is None:
|
|
663
|
+
_print_error(f"{name}: component path not found")
|
|
664
|
+
all_pass = False
|
|
665
|
+
continue
|
|
666
|
+
if not repo_path.is_dir():
|
|
667
|
+
_print_error(f"{name}: component path does not exist at '{repo_path}'")
|
|
668
|
+
all_pass = False
|
|
669
|
+
continue
|
|
670
|
+
if not _check_entry(
|
|
671
|
+
name, repo_path, entry,
|
|
672
|
+
components_vers.get(name),
|
|
673
|
+
release=release, dirty_allowed=dirty_allowed,
|
|
674
|
+
context=context, check_targets=False,
|
|
675
|
+
warnings=warnings, strict=strict,
|
|
676
|
+
):
|
|
677
|
+
all_pass = False
|
|
678
|
+
|
|
679
|
+
return all_pass, warnings
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _resolve_component_path(name: str, deps_entry: dict, context) -> Path | None:
|
|
683
|
+
"""解析组件路径。默认:<project_dir>/components/<name>。"""
|
|
684
|
+
custom_path = deps_entry.get("path")
|
|
685
|
+
if custom_path:
|
|
686
|
+
return context.project_dir / custom_path
|
|
687
|
+
return context.project_dir / "components" / name
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _check_entry(
|
|
691
|
+
name: str,
|
|
692
|
+
repo_path: Path,
|
|
693
|
+
deps_entry: dict,
|
|
694
|
+
vers_entry: dict | str | None,
|
|
695
|
+
*,
|
|
696
|
+
release: bool,
|
|
697
|
+
dirty_allowed: bool,
|
|
698
|
+
context,
|
|
699
|
+
check_targets: bool,
|
|
700
|
+
warnings: list[tuple[str, list[str]]] | None = None,
|
|
701
|
+
strict: bool = False,
|
|
702
|
+
) -> bool:
|
|
703
|
+
"""检查单个依赖条目。返回 True=通过。"""
|
|
704
|
+
require = deps_entry.get("require")
|
|
705
|
+
has_require = require is not None
|
|
706
|
+
|
|
707
|
+
# 判断 require 是否为 semver 约束
|
|
708
|
+
require_is_semver = False
|
|
709
|
+
if isinstance(require, str):
|
|
710
|
+
require_is_semver = _identify_val_type(require) == "semver"
|
|
711
|
+
elif isinstance(require, dict) and require.get("type") == "semver":
|
|
712
|
+
require_is_semver = True
|
|
713
|
+
|
|
714
|
+
# 获取当前版本
|
|
715
|
+
if require_is_semver:
|
|
716
|
+
current_ver = _get_semver_version(repo_path)
|
|
717
|
+
if current_ver is None:
|
|
718
|
+
_print_error(f"{name}: no semver tag found for '{repo_path}'")
|
|
719
|
+
return False
|
|
720
|
+
current_sha = current_ver
|
|
721
|
+
is_dirty = False
|
|
722
|
+
version_str = current_ver
|
|
723
|
+
elif isinstance(require, dict) and require.get("type") == "hash":
|
|
724
|
+
current_sha = hash_dir(repo_path)
|
|
725
|
+
is_dirty = False
|
|
726
|
+
version_str = current_sha
|
|
727
|
+
elif not (repo_path / ".git").exists():
|
|
728
|
+
current_sha = hash_dir(repo_path)
|
|
729
|
+
is_dirty = False
|
|
730
|
+
version_str = current_sha
|
|
731
|
+
else:
|
|
732
|
+
try:
|
|
733
|
+
current_sha, is_dirty = _get_git_version(repo_path)
|
|
734
|
+
except subprocess.CalledProcessError:
|
|
735
|
+
_print_error(f"{name}: unable to get git version from '{repo_path}'")
|
|
736
|
+
return False
|
|
737
|
+
version_str = f"{current_sha}{'-dirty' if is_dirty else ''}"
|
|
738
|
+
|
|
739
|
+
# 首次追踪
|
|
740
|
+
if vers_entry is None:
|
|
741
|
+
_print_info(f"{name}: first time tracking (commit: {version_str})")
|
|
742
|
+
return True
|
|
743
|
+
|
|
744
|
+
recorded_version = _extract_version(vers_entry)
|
|
745
|
+
|
|
746
|
+
if has_require:
|
|
747
|
+
# 有 require → 约束检查
|
|
748
|
+
if not _check_version_require(require, current_sha, repo_path):
|
|
749
|
+
on_fail = _resolve_on_fail(deps_entry, "version", context)
|
|
750
|
+
if strict and on_fail != "error":
|
|
751
|
+
on_fail = "error"
|
|
752
|
+
if on_fail == "error":
|
|
753
|
+
_print_error(
|
|
754
|
+
f"{name}: version constraint not satisfied.\n"
|
|
755
|
+
f" Required: {require}\n"
|
|
756
|
+
f" Current: {current_sha}"
|
|
757
|
+
)
|
|
758
|
+
return False
|
|
759
|
+
if on_fail == "warn":
|
|
760
|
+
if warnings is not None:
|
|
761
|
+
warnings.append((f"{name}: version constraint not satisfied.", [f"Required: {require}", f"Current: {current_sha}"]))
|
|
762
|
+
# 继续检查 dirty 和 targets(release mode 下 dirty 仍可中止)
|
|
763
|
+
else:
|
|
764
|
+
# 无 require → 纯追踪模式:对比 xt_vers
|
|
765
|
+
if recorded_version != version_str:
|
|
766
|
+
if warnings is not None:
|
|
767
|
+
warnings.append((f"{name}: commit changed since last recorded build.", [f"Recorded: {recorded_version}", f"Current: {version_str}"]))
|
|
768
|
+
|
|
769
|
+
# dirty 检查
|
|
770
|
+
if is_dirty:
|
|
771
|
+
should_abort = (release or strict) and not dirty_allowed
|
|
772
|
+
if should_abort:
|
|
773
|
+
_print_error(
|
|
774
|
+
f"{name}: repository is dirty.\n"
|
|
775
|
+
f" Current: {version_str}\n"
|
|
776
|
+
f" Use --dirty to allow."
|
|
777
|
+
)
|
|
778
|
+
return False
|
|
779
|
+
if warnings is not None:
|
|
780
|
+
warnings.append((f"{name}: repository is dirty.", [f"Current: {version_str}"]))
|
|
781
|
+
|
|
782
|
+
# targets 检查
|
|
783
|
+
if check_targets:
|
|
784
|
+
targets_constraint = _resolve_targets_constraint(deps_entry)
|
|
785
|
+
if targets_constraint is not None:
|
|
786
|
+
_, target_name = _split_target(context.target)
|
|
787
|
+
if not _check_target_match(target_name, targets_constraint):
|
|
788
|
+
on_fail = _resolve_on_fail(deps_entry, "target", context)
|
|
789
|
+
if strict and on_fail != "error":
|
|
790
|
+
on_fail = "error"
|
|
791
|
+
msg = (
|
|
792
|
+
f"{name}: target '{target_name}' not in constraint.\n"
|
|
793
|
+
f" Allowed: {targets_constraint}"
|
|
794
|
+
)
|
|
795
|
+
if on_fail == "error":
|
|
796
|
+
_print_error(msg)
|
|
797
|
+
return False
|
|
798
|
+
if on_fail == "warn":
|
|
799
|
+
if warnings is not None:
|
|
800
|
+
warnings.append((msg.split("\n")[0], msg.split("\n")[1:]))
|
|
801
|
+
return True
|
|
802
|
+
|
|
803
|
+
return True
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _check_version_require(require_val, current_sha: str, repo_path: Path) -> bool:
|
|
807
|
+
"""检查当前版本是否满足 require 约束。全部满足才通过。"""
|
|
808
|
+
if isinstance(require_val, dict):
|
|
809
|
+
val_type = require_val.get("type")
|
|
810
|
+
if val_type == "hash":
|
|
811
|
+
current_hash = hash_dir(repo_path)
|
|
812
|
+
return current_hash == require_val.get("val", "")
|
|
813
|
+
if val_type == "git":
|
|
814
|
+
if not (repo_path / ".git").exists():
|
|
815
|
+
_print_error(f"require type is git but '{repo_path}' is not a git repository")
|
|
816
|
+
return False
|
|
817
|
+
return current_sha == require_val.get("val", "")[:len(current_sha)]
|
|
818
|
+
return True # 未知 type 值,跳过
|
|
819
|
+
if not isinstance(require_val, str):
|
|
820
|
+
_print_warning(
|
|
821
|
+
"require: unexpected type, skipping.",
|
|
822
|
+
[f"Type: {type(require_val).__name__}, Value: {require_val}"],
|
|
823
|
+
)
|
|
824
|
+
return True
|
|
825
|
+
# 字符串 require:自动检测类型
|
|
826
|
+
if not (repo_path / ".git").exists():
|
|
827
|
+
return hash_dir(repo_path) == require_val
|
|
828
|
+
constraints = _parse_require(require_val)
|
|
829
|
+
for op, version in constraints:
|
|
830
|
+
val_type = _identify_val_type(version)
|
|
831
|
+
if val_type == "git":
|
|
832
|
+
if not _check_sha_constraint(op, version, current_sha, repo_path):
|
|
833
|
+
return False
|
|
834
|
+
elif val_type == "semver":
|
|
835
|
+
if not _check_semver_constraint(op, version, current_sha):
|
|
836
|
+
return False
|
|
837
|
+
return True
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _split_target(target: str) -> tuple[str, str]:
|
|
841
|
+
"""从 target 字符串中分离平台名和目标名。
|
|
842
|
+
|
|
843
|
+
"lm620/r4f4" → ("lm620", "r4f4")
|
|
844
|
+
"windows/simulator" → ("windows", "simulator")
|
|
845
|
+
"""
|
|
846
|
+
parts = target.split("/", 1)
|
|
847
|
+
if len(parts) == 2:
|
|
848
|
+
return parts[0], parts[1]
|
|
849
|
+
return parts[0], ""
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _check_target_match(target_name: str, constraints: list[str]) -> bool:
|
|
853
|
+
"""检查 target 是否满足约束。
|
|
854
|
+
|
|
855
|
+
constraints 如 ["r4f4", "!= r4f2"]:
|
|
856
|
+
- 普通字符串表示允许
|
|
857
|
+
- != 前缀表示排除
|
|
858
|
+
有任意允许项且无排除项 → True。
|
|
859
|
+
"""
|
|
860
|
+
allowed: list[str] = []
|
|
861
|
+
excluded: list[str] = []
|
|
862
|
+
for c in constraints:
|
|
863
|
+
c = c.strip()
|
|
864
|
+
if c.startswith("!="):
|
|
865
|
+
excluded.append(c[2:].strip())
|
|
866
|
+
else:
|
|
867
|
+
allowed.append(c)
|
|
868
|
+
if excluded and target_name in excluded:
|
|
869
|
+
return False
|
|
870
|
+
if allowed:
|
|
871
|
+
return target_name in allowed
|
|
872
|
+
return True
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# ============================================================
|
|
876
|
+
# Task 7: update_dependencies 核心逻辑
|
|
877
|
+
# ============================================================
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def update_dependencies(context) -> None:
|
|
881
|
+
"""after build: 收集实际版本,写入 xt_vers.jsonc。
|
|
882
|
+
|
|
883
|
+
编译成功后调用,在 after_build hook 之后。
|
|
884
|
+
"""
|
|
885
|
+
xt_vers = _load_jsonc(context.project_dir / "xt_vers.jsonc")
|
|
886
|
+
if xt_vers is None:
|
|
887
|
+
xt_vers = {"config_version": 1}
|
|
888
|
+
|
|
889
|
+
# 更新 xt-sdk
|
|
890
|
+
xt_vers["xt-sdk"] = {"version": _get_entry_version(context.sdk_dir)}
|
|
891
|
+
|
|
892
|
+
# 更新当前平台
|
|
893
|
+
platform_name = context.platform_name
|
|
894
|
+
if "platforms" not in xt_vers:
|
|
895
|
+
xt_vers["platforms"] = {}
|
|
896
|
+
|
|
897
|
+
xt_deps = _load_jsonc(context.project_dir / "xt_deps.jsonc")
|
|
898
|
+
platforms_deps = xt_deps.get("platforms", {}) if xt_deps else {}
|
|
899
|
+
platform_entry_deps = platforms_deps.get(platform_name, {})
|
|
900
|
+
repo_path = _resolve_repo_path(platform_name, platform_entry_deps, context)
|
|
901
|
+
|
|
902
|
+
if repo_path:
|
|
903
|
+
version_str = _get_entry_version(repo_path)
|
|
904
|
+
else:
|
|
905
|
+
version_str = xt_vers["xt-sdk"]["version"]
|
|
906
|
+
|
|
907
|
+
# 追加 target(编译成功才更新,防止重复)
|
|
908
|
+
_, target_name = _split_target(context.target)
|
|
909
|
+
existing_entry = xt_vers["platforms"].get(platform_name)
|
|
910
|
+
existing_targets = _extract_targets(existing_entry)
|
|
911
|
+
|
|
912
|
+
if target_name and target_name not in existing_targets:
|
|
913
|
+
existing_targets.append(target_name)
|
|
914
|
+
|
|
915
|
+
platform_data: dict[str, Any] = {"version": version_str}
|
|
916
|
+
if existing_targets:
|
|
917
|
+
platform_data["targets"] = existing_targets
|
|
918
|
+
xt_vers["platforms"][platform_name] = platform_data
|
|
919
|
+
|
|
920
|
+
# 更新 components(写入 xt_vers)
|
|
921
|
+
components_deps = xt_deps.get("components", {}) if xt_deps else {}
|
|
922
|
+
if isinstance(components_deps, dict) and components_deps:
|
|
923
|
+
if "components" not in xt_vers:
|
|
924
|
+
xt_vers["components"] = {}
|
|
925
|
+
for name, entry in components_deps.items():
|
|
926
|
+
if not isinstance(entry, dict):
|
|
927
|
+
continue
|
|
928
|
+
repo_path = _resolve_component_path(name, entry, context)
|
|
929
|
+
if repo_path and repo_path.is_dir():
|
|
930
|
+
xt_vers["components"][name] = _get_version_entry(repo_path, entry)
|
|
931
|
+
|
|
932
|
+
_save_jsonc(context.project_dir / "xt_vers.jsonc", xt_vers)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _get_version_entry(repo_path: Path, deps_entry: dict | None = None) -> dict[str, Any]:
|
|
936
|
+
"""获取条目版本对象。
|
|
937
|
+
|
|
938
|
+
返回值始终为 {"version": "..."},非默认 git 类型追加 "source"。
|
|
939
|
+
"""
|
|
940
|
+
require = deps_entry.get("require") if deps_entry else None
|
|
941
|
+
is_hash = isinstance(require, dict) and require.get("type") == "hash"
|
|
942
|
+
val_type = "hash" if is_hash else None
|
|
943
|
+
version = _get_entry_version(repo_path, val_type)
|
|
944
|
+
entry: dict[str, Any] = {"version": version}
|
|
945
|
+
if is_hash:
|
|
946
|
+
entry["source"] = {"type": "hash"}
|
|
947
|
+
return entry
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def hash_dir(dir_path: str | Path) -> str:
|
|
951
|
+
"""计算目录内容 SHA-256,返回前 8 位十六进制。
|
|
952
|
+
|
|
953
|
+
算法:遍历目录 → 排序文件 → 文件名哈希 → 内容分块 SHA-256。
|
|
954
|
+
跳过 .git 目录,自动读取各级 .gitignore 过滤文件。
|
|
955
|
+
"""
|
|
956
|
+
dir_path = Path(dir_path)
|
|
957
|
+
root_rules = _load_gitignore(dir_path)
|
|
958
|
+
sha = hashlib.sha256()
|
|
959
|
+
for root, dirs, files in _os.walk(dir_path):
|
|
960
|
+
if '.git' in dirs:
|
|
961
|
+
dirs.remove('.git')
|
|
962
|
+
# 子目录的 .gitignore 规则(相对当前 root)
|
|
963
|
+
dirs.sort()
|
|
964
|
+
sub_rules = root_rules + _load_gitignore(Path(root))
|
|
965
|
+
filtered_dirs = []
|
|
966
|
+
for d in dirs:
|
|
967
|
+
rel = _os.path.relpath(_os.path.join(root, d), dir_path).replace('\\', '/')
|
|
968
|
+
if not _match_gitignore(rel + '/', sub_rules):
|
|
969
|
+
filtered_dirs.append(d)
|
|
970
|
+
dirs[:] = filtered_dirs
|
|
971
|
+
for filename in sorted(files):
|
|
972
|
+
filepath = _os.path.join(root, filename)
|
|
973
|
+
relpath = _os.path.relpath(filepath, dir_path).replace('\\', '/')
|
|
974
|
+
# 跳过 .gitignore 自身
|
|
975
|
+
if filename == ".gitignore":
|
|
976
|
+
sha.update(relpath.encode('utf-8'))
|
|
977
|
+
continue
|
|
978
|
+
if _match_gitignore(relpath, sub_rules):
|
|
979
|
+
continue
|
|
980
|
+
sha.update(relpath.encode('utf-8'))
|
|
981
|
+
with open(filepath, 'rb') as f:
|
|
982
|
+
while True:
|
|
983
|
+
block = f.read(65536)
|
|
984
|
+
if not block:
|
|
985
|
+
break
|
|
986
|
+
sha.update(block)
|
|
987
|
+
return sha.hexdigest()[:8]
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _load_gitignore(dir_path: Path) -> list[tuple[str, bool]]:
|
|
991
|
+
"""读取 .gitignore,返回 [(pattern, is_negate)] 规则列表。"""
|
|
992
|
+
gitignore = dir_path / ".gitignore"
|
|
993
|
+
if not gitignore.is_file():
|
|
994
|
+
return []
|
|
995
|
+
rules: list[tuple[str, bool]] = []
|
|
996
|
+
for line in gitignore.read_text(encoding="utf-8").splitlines():
|
|
997
|
+
line = line.strip()
|
|
998
|
+
if not line or line.startswith("#"):
|
|
999
|
+
continue
|
|
1000
|
+
is_negate = line.startswith("!")
|
|
1001
|
+
if is_negate:
|
|
1002
|
+
line = line[1:]
|
|
1003
|
+
if line:
|
|
1004
|
+
rules.append((line, is_negate))
|
|
1005
|
+
return rules
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _match_gitignore(relpath: str, rules: list[tuple[str, bool]]) -> bool:
|
|
1009
|
+
"""检查路径是否匹配 .gitignore 规则。最后命中的规则生效。"""
|
|
1010
|
+
ignored = False
|
|
1011
|
+
for pattern, is_negate in rules:
|
|
1012
|
+
if _gitignore_match(relpath, pattern):
|
|
1013
|
+
ignored = not is_negate
|
|
1014
|
+
return ignored
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _gitignore_match(relpath: str, pattern: str) -> bool:
|
|
1018
|
+
"""单个 .gitignore 模式匹配。支持 * ? [seq] ** / 锚定。"""
|
|
1019
|
+
is_dir_pattern = pattern.endswith("/")
|
|
1020
|
+
if is_dir_pattern:
|
|
1021
|
+
pattern = pattern[:-1]
|
|
1022
|
+
relpath = relpath.rstrip("/")
|
|
1023
|
+
|
|
1024
|
+
# 不含 / 且不含 **,匹配任意深度的文件名
|
|
1025
|
+
if "/" not in pattern and "**" not in pattern:
|
|
1026
|
+
return (fnmatch.fnmatch(relpath, pattern) or
|
|
1027
|
+
fnmatch.fnmatch(relpath.split("/")[-1], pattern))
|
|
1028
|
+
|
|
1029
|
+
# 含 **,特殊处理
|
|
1030
|
+
if "**" in pattern:
|
|
1031
|
+
return _match_double_star(relpath, pattern)
|
|
1032
|
+
|
|
1033
|
+
# 含 /,从根精确路径匹配(目录标记时 relpath 尾部加 / 用于区分)
|
|
1034
|
+
if is_dir_pattern:
|
|
1035
|
+
return fnmatch.fnmatch(relpath + "/", pattern + "/")
|
|
1036
|
+
return fnmatch.fnmatch(relpath, pattern)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _match_double_star(relpath: str, pattern: str) -> bool:
|
|
1040
|
+
"""处理 ** 通配符,转换为正则匹配。"""
|
|
1041
|
+
regex = "^"
|
|
1042
|
+
i = 0
|
|
1043
|
+
while i < len(pattern):
|
|
1044
|
+
if pattern[i:i+2] == "**":
|
|
1045
|
+
if i + 2 < len(pattern) and pattern[i+2] == "/":
|
|
1046
|
+
regex += r"(.*/)?"
|
|
1047
|
+
i += 3 # skip **/
|
|
1048
|
+
else:
|
|
1049
|
+
regex += r".*"
|
|
1050
|
+
i += 2
|
|
1051
|
+
elif pattern[i] == "*":
|
|
1052
|
+
regex += r"[^/]*"
|
|
1053
|
+
i += 1
|
|
1054
|
+
elif pattern[i] == "?":
|
|
1055
|
+
regex += r"[^/]"
|
|
1056
|
+
i += 1
|
|
1057
|
+
elif pattern[i] in ".+^$(){}|\\":
|
|
1058
|
+
regex += "\\" + pattern[i]
|
|
1059
|
+
i += 1
|
|
1060
|
+
else:
|
|
1061
|
+
regex += pattern[i]
|
|
1062
|
+
i += 1
|
|
1063
|
+
regex += "$"
|
|
1064
|
+
return bool(re.match(regex, relpath))
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _get_entry_version(repo_path: Path, val_type: str | None = None) -> str:
|
|
1068
|
+
"""获取条目版本字符串。
|
|
1069
|
+
|
|
1070
|
+
val_type=None → 自动检测(semver > git sha > hash)
|
|
1071
|
+
val_type="semver" → semver tag
|
|
1072
|
+
val_type="git" → git sha
|
|
1073
|
+
val_type="hash" → 目录哈希
|
|
1074
|
+
"""
|
|
1075
|
+
if val_type == "hash":
|
|
1076
|
+
return hash_dir(repo_path)
|
|
1077
|
+
if val_type == "git":
|
|
1078
|
+
sha, is_dirty = _get_git_version(repo_path)
|
|
1079
|
+
return f"{sha}{'-dirty' if is_dirty else ''}"
|
|
1080
|
+
if val_type == "semver" or (repo_path / ".git").exists():
|
|
1081
|
+
sv = _get_semver_version(repo_path)
|
|
1082
|
+
if sv:
|
|
1083
|
+
return sv
|
|
1084
|
+
if val_type == "semver":
|
|
1085
|
+
return ""
|
|
1086
|
+
sha, is_dirty = _get_git_version(repo_path)
|
|
1087
|
+
return f"{sha}{'-dirty' if is_dirty else ''}"
|
|
1088
|
+
return hash_dir(repo_path)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _extract_targets(entry) -> list[str]:
|
|
1092
|
+
"""从 xt_vers 条目提取 targets 列表(兼容新旧格式)。"""
|
|
1093
|
+
if isinstance(entry, dict):
|
|
1094
|
+
targets = entry.get("targets")
|
|
1095
|
+
if isinstance(targets, list):
|
|
1096
|
+
return list(targets)
|
|
1097
|
+
return []
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _extract_version(entry) -> str:
|
|
1101
|
+
"""从 xt_vers 条目提取 version 字符串。
|
|
1102
|
+
|
|
1103
|
+
当前格式为 {"version": "sha"},兼容旧版短字符串格式。
|
|
1104
|
+
"""
|
|
1105
|
+
if isinstance(entry, str):
|
|
1106
|
+
return entry
|
|
1107
|
+
if isinstance(entry, dict):
|
|
1108
|
+
return entry.get("version", "")
|
|
1109
|
+
return ""
|