godot-cli-control 0.2.11__tar.gz → 0.2.12__tar.gz

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.
Files changed (57) hide show
  1. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/PKG-INFO +1 -1
  2. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/README.md +2 -2
  3. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/_version.py +2 -2
  4. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/cli.py +10 -1
  5. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/init_cmd.py +88 -0
  6. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/templates/skill/SKILL.md +1 -1
  7. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_cli.py +1 -0
  8. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_init.py +183 -0
  9. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/.gitignore +0 -0
  10. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/LICENSE +0 -0
  11. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/CHANGELOG.md +0 -0
  12. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/LICENSE +0 -0
  13. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  14. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  15. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
  16. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/game_bridge.gd +0 -0
  17. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  18. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
  19. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  20. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
  21. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  22. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/plugin.cfg +0 -0
  23. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/plugin.gd +0 -0
  24. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/plugin.gd.uid +0 -0
  25. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +0 -0
  26. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
  27. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
  28. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/run_gut.py +0 -0
  29. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  30. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  31. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  32. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/pyproject.toml +0 -0
  33. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/README.md +0 -0
  34. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/__init__.py +0 -0
  35. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/__main__.py +0 -0
  36. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/_duration.py +0 -0
  37. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/bridge.py +0 -0
  38. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/client.py +0 -0
  39. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/daemon.py +0 -0
  40. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/pytest_plugin.py +0 -0
  41. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/registry.py +0 -0
  42. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/runner.py +0 -0
  43. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/skills_install.py +0 -0
  44. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/templates/__init__.py +0 -0
  45. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  46. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/__init__.py +0 -0
  47. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_bridge.py +0 -0
  48. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_cli_helpers.py +0 -0
  49. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_client.py +0 -0
  50. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_daemon.py +0 -0
  51. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_duration.py +0 -0
  52. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_e2e_input.py +0 -0
  53. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_e2e_screenshot_gui.py +0 -0
  54. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_pytest_plugin.py +0 -0
  55. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_registry.py +0 -0
  56. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_runner.py +0 -0
  57. {godot_cli_control-0.2.11 → godot_cli_control-0.2.12}/python/tests/test_skills_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.2.11
3
+ Version: 0.2.12
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
5
  Author: kesar
6
6
  License: MIT
@@ -9,13 +9,13 @@ If you have the Python CLI installed (`pipx install godot-cli-control`), the ent
9
9
 
10
10
  ```bash
11
11
  cd your_godot_project
12
- godot-cli-control init # copy plugin, patch project.godot, detect Godot
12
+ godot-cli-control init # copy plugin, patch project.godot, detect Godot, gitignore .cli_control/
13
13
  godot-cli-control daemon start
14
14
  godot-cli-control tree 3
15
15
  godot-cli-control daemon stop
16
16
  ```
17
17
 
18
- `init` automates everything described in the manual setup below — copying the addon, editing `project.godot`, and detecting your Godot binary.
18
+ `init` automates everything described in the manual setup below — copying the addon, editing `project.godot`, and detecting your Godot binary. It also appends `.cli_control/` to the project's root `.gitignore` (the daemon's machine-local state dir: detected binary path, pid, port, log, recording intermediates), so that state never gets committed. Pass `--no-gitignore` to skip that step.
19
19
 
20
20
  ## Manual Setup (if you prefer)
21
21
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.11'
22
- __version_tuple__ = version_tuple = (0, 2, 11)
21
+ __version__ = version = '0.2.12'
22
+ __version_tuple__ = version_tuple = (0, 2, 12)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1264,6 +1264,7 @@ def cmd_init(ns: argparse.Namespace) -> int:
1264
1264
  write_skills=not ns.no_skills,
1265
1265
  skills_only=ns.skills_only,
1266
1266
  clobber_skills=not ns.skills_no_clobber,
1267
+ write_gitignore=not ns.no_gitignore,
1267
1268
  output_format=fmt,
1268
1269
  result=result,
1269
1270
  )
@@ -1569,7 +1570,7 @@ def build_parser() -> argparse.ArgumentParser:
1569
1570
  help="在 Godot 项目根一键接入插件",
1570
1571
  description=(
1571
1572
  "复制 addons/godot_cli_control 到目标项目、patch project.godot 启用插件、"
1572
- "校验 GODOT_BIN"
1573
+ "校验 GODOT_BIN、在 .gitignore 忽略 .cli_control/。"
1573
1574
  ),
1574
1575
  epilog=(
1575
1576
  "GODOT_BIN 查找顺序:\n"
@@ -1601,6 +1602,14 @@ def build_parser() -> argparse.ArgumentParser:
1601
1602
  "保证版本与 CLI 帮助同步)。与 --no-skills / --skills-only 都兼容。"
1602
1603
  ),
1603
1604
  )
1605
+ init_p.add_argument(
1606
+ "--no-gitignore",
1607
+ action="store_true",
1608
+ help=(
1609
+ "跳过往项目根 .gitignore 追加 .cli_control/(默认会追加,"
1610
+ "忽略 daemon 的机器本地状态目录)。--skills-only 模式下本就跳过。"
1611
+ ),
1612
+ )
1604
1613
  skills_group = init_p.add_mutually_exclusive_group()
1605
1614
  skills_group.add_argument(
1606
1615
  "--no-skills",
@@ -8,6 +8,8 @@
8
8
  绕过 Godot Editor GUI 启用步骤
9
9
  4. 自动检测 Godot 二进制,写入 ``.cli_control/godot_bin``,daemon 启动时
10
10
  会优先读取此文件
11
+ 5. 在项目根 ``.gitignore`` 追加 ``.cli_control/`` —— daemon 的机器本地状态
12
+ 目录(godot_bin 绝对路径 / pid / port / log / 录制中间产物),不应提交
11
13
 
12
14
  完成后用户只需 ``godot-cli-control daemon start`` 即可。
13
15
  """
@@ -26,6 +28,12 @@ from .daemon import find_godot_binary, reimport_project
26
28
  PLUGIN_DIR_NAME = "godot_cli_control"
27
29
  ADDONS_DIRNAME = "addons"
28
30
 
31
+ GITIGNORE_NAME = ".gitignore"
32
+ # 需要 init 写进目标项目 .gitignore 的条目。用元组而非单值,给将来"增量加
33
+ # 忽略项"留口子(与 method_blacklist_extra 同样的增量哲学);目前只有 daemon
34
+ # 的机器本地状态目录 .cli_control/。
35
+ GITIGNORE_ENTRIES: tuple[str, ...] = (".cli_control/",)
36
+
29
37
  AUTOLOAD_KEY = "GameBridgeNode"
30
38
  AUTOLOAD_VALUE = '"*res://addons/godot_cli_control/bridge/game_bridge.gd"'
31
39
  PLUGIN_CFG_PATH = "res://addons/godot_cli_control/plugin.cfg"
@@ -43,6 +51,7 @@ def run_init(
43
51
  write_skills: bool = True,
44
52
  skills_only: bool = False,
45
53
  clobber_skills: bool = True,
54
+ write_gitignore: bool = True,
46
55
  *,
47
56
  output_format: str = "text",
48
57
  result: dict[str, Any] | None = None,
@@ -56,6 +65,9 @@ def run_init(
56
65
  ``clobber_skills=False``:写 skill 时遇到已存在文件就跳过(用户改过
57
66
  SKILL.md、希望保留本地版又允许 init 把缺失那条补上时用)。与
58
67
  ``write_skills=False`` / ``skills_only=True`` 都兼容。
68
+ ``write_gitignore=False``:跳过往项目根 ``.gitignore`` 追加 ``.cli_control/``
69
+ (不想让 init 碰 ``.gitignore`` 的用户逃生口)。该步骤属于"项目接入"阶段,
70
+ ``skills_only=True`` 时本就跳过。
59
71
 
60
72
  ``output_format='json'``:抑制全部人类可读 print;调用方(cli.cmd_init)
61
73
  传入 ``result`` 字典,本函数会回填结构化字段,由 cli 侧封 JSON envelope。
@@ -93,6 +105,7 @@ def run_init(
93
105
  project_godot_changes=[],
94
106
  godot_bin=None,
95
107
  skills_written=[],
108
+ gitignore_added=[],
96
109
  )
97
110
 
98
111
  if not (project_root / "project.godot").is_file():
@@ -157,6 +170,15 @@ def run_init(
157
170
  "(跳过资源重新导入;首次 daemon start 会兜底重建 cache)"
158
171
  )
159
172
 
173
+ # .gitignore:无条件确保(不依赖 godot_bin 是否检测到——daemon 早晚
174
+ # 会建 .cli_control/,提前忽略最稳)。属于"项目接入"阶段,故在
175
+ # not skills_only 块内。
176
+ if write_gitignore:
177
+ added = _ensure_gitignore_entries(project_root)
178
+ if added:
179
+ _say(f"修改 .gitignore:忽略 {', '.join(added)}")
180
+ _record(gitignore_added=added)
181
+
160
182
  if write_skills:
161
183
  # lazy import:保持与 cli.cmd_init 那侧 `from .init_cmd import run_init`
162
184
  # 对称,两边都不在模块顶层 import,避免任一方将来改成顶层 import
@@ -319,3 +341,69 @@ def _ensure_in_packed_array(
319
341
  body.rstrip("\n") + "\n" + f'{key}=PackedStringArray("{value}")\n'
320
342
  )
321
343
  return text[:start] + new_body + text[end:], True
344
+
345
+
346
+ # ── .gitignore 维护 ──
347
+
348
+
349
+ def _gitignore_has_entry(text: str, entry: str) -> bool:
350
+ """``.gitignore`` 文本里是否已忽略 ``entry``。
351
+
352
+ 宽松匹配:忽略前导 ``/`` 与尾随 ``/``、行首尾空白,所以 ``.cli_control``、
353
+ ``.cli_control/``、``/.cli_control/`` 视为同一条,不重复加。注释行(``#`` 开头)
354
+ 不算数 —— 用户把它注释掉就是想让它失效,真条目仍要补。
355
+ """
356
+ target = entry.strip().strip("/")
357
+ for line in text.splitlines():
358
+ stripped = line.strip()
359
+ if not stripped or stripped.startswith("#"):
360
+ continue
361
+ if stripped.strip("/") == target:
362
+ return True
363
+ return False
364
+
365
+
366
+ def _add_gitignore_entries(
367
+ text: str, entries: tuple[str, ...] | list[str]
368
+ ) -> tuple[str, list[str]]:
369
+ """纯函数:把缺失的 ``entries`` 追加到 ``.gitignore`` 文本末尾。
370
+
371
+ 返回 ``(新文本, 实际新增的条目列表)``。已存在的条目跳过。追加前确保末尾
372
+ 有换行,避免把新条目黏到原末行后面;空文本不留前导空行。行尾统一用
373
+ ``\\n``,CRLF 回写交给 IO 包装层(与 ``_patch_project_godot`` 同构)。
374
+ """
375
+ added: list[str] = []
376
+ out = text
377
+ for entry in entries:
378
+ if _gitignore_has_entry(out, entry):
379
+ continue
380
+ if out and not out.endswith("\n"):
381
+ out += "\n"
382
+ out += entry + "\n"
383
+ added.append(entry)
384
+ return out, added
385
+
386
+
387
+ def _ensure_gitignore_entries(
388
+ project_root: Path, entries: tuple[str, ...] = GITIGNORE_ENTRIES
389
+ ) -> list[str]:
390
+ """确保项目根 ``.gitignore`` 忽略 ``entries``;返回实际新增的条目。
391
+
392
+ 文件不存在则创建(LF)。已存在则沿用 ``_patch_project_godot`` 的 bytes+CRLF
393
+ 思路:读 bytes、探测原文是否 CRLF、解码时折成 LF 便于匹配,仅在确有新增时
394
+ 回写并把行尾翻回原样 —— 保 Windows 上 git diff 不被整文件行尾翻转污染。
395
+ """
396
+ path = project_root / GITIGNORE_NAME
397
+ if path.exists():
398
+ raw = path.read_bytes()
399
+ is_crlf = b"\r\n" in raw
400
+ text = raw.decode("utf-8").replace("\r\n", "\n")
401
+ else:
402
+ is_crlf = False
403
+ text = ""
404
+
405
+ new_text, added = _add_gitignore_entries(text, entries)
406
+ if added:
407
+ out = new_text.replace("\n", "\r\n") if is_crlf else new_text
408
+ path.write_bytes(out.encode("utf-8"))
409
+ return added
@@ -85,7 +85,7 @@ $ godot-cli-control --text exists /root/Main
85
85
  true
86
86
 
87
87
  $ godot-cli-control init
88
- {"ok": true, "result": {"project_root": "/path/to/proj", "plugin_copied": true, "plugin_overwritten": false, "project_godot_changes": ["autoload/GameBridgeNode", "editor_plugins/enabled"], "godot_bin": "/usr/bin/godot", "skills_written": [".../SKILL.md", ".../SKILL.md"], "skills_only": false, "write_skills": true}}
88
+ {"ok": true, "result": {"project_root": "/path/to/proj", "plugin_copied": true, "plugin_overwritten": false, "project_godot_changes": ["autoload/GameBridgeNode", "editor_plugins/enabled"], "godot_bin": "/usr/bin/godot", "skills_written": [".../SKILL.md", ".../SKILL.md"], "gitignore_added": [".cli_control/"], "skills_only": false, "write_skills": true}}
89
89
 
90
90
  $ godot-cli-control run my_script.py
91
91
  {"ok": true, "result": {"exit_code": 0, "script": "my_script.py"}}
@@ -723,6 +723,7 @@ def test_cmd_init_catches_unexpected_exception_into_envelope(
723
723
  no_skills=False,
724
724
  skills_only=False,
725
725
  skills_no_clobber=False,
726
+ no_gitignore=False,
726
727
  output_format=OUTPUT_JSON,
727
728
  )
728
729
  rc = cmd_init(ns)
@@ -428,6 +428,7 @@ def test_cmd_init_emits_success_envelope_in_json_mode(
428
428
  no_skills=False,
429
429
  skills_only=False,
430
430
  skills_no_clobber=False,
431
+ no_gitignore=False,
431
432
  output_format=OUTPUT_JSON,
432
433
  )
433
434
  rc = cmd_init(ns)
@@ -459,6 +460,7 @@ def test_cmd_init_emits_error_envelope_on_non_godot_dir(
459
460
  no_skills=False,
460
461
  skills_only=False,
461
462
  skills_no_clobber=False,
463
+ no_gitignore=False,
462
464
  output_format=OUTPUT_JSON,
463
465
  )
464
466
  rc = cmd_init(ns)
@@ -469,3 +471,184 @@ def test_cmd_init_emits_error_envelope_on_non_godot_dir(
469
471
  assert payload["ok"] is False
470
472
  assert payload["error"]["code"] == -1003 # CLIENT_CODE_USAGE
471
473
  assert "project.godot" in payload["error"]["message"]
474
+
475
+
476
+ # ── .gitignore 维护(确保 .cli_control/ 不被提交)──
477
+
478
+
479
+ def test_gitignore_adds_entry_to_empty_text() -> None:
480
+ """纯函数:空文本追加条目。"""
481
+ from godot_cli_control.init_cmd import _add_gitignore_entries
482
+
483
+ out, added = _add_gitignore_entries("", [".cli_control/"])
484
+ assert added == [".cli_control/"]
485
+ assert out == ".cli_control/\n"
486
+ # 不允许出现前导空行
487
+ assert not out.startswith("\n")
488
+
489
+
490
+ def test_gitignore_appends_preserving_existing_entries() -> None:
491
+ """已有别的忽略项时只追加、不动原内容。"""
492
+ from godot_cli_control.init_cmd import _add_gitignore_entries
493
+
494
+ text = "*.tmp\nbuild/\n"
495
+ out, added = _add_gitignore_entries(text, [".cli_control/"])
496
+ assert added == [".cli_control/"]
497
+ assert "*.tmp" in out
498
+ assert "build/" in out
499
+ assert out.endswith(".cli_control/\n")
500
+
501
+
502
+ def test_gitignore_idempotent_when_entry_present() -> None:
503
+ """已含完全相同条目 → 不重复加。"""
504
+ from godot_cli_control.init_cmd import _add_gitignore_entries
505
+
506
+ text = "build/\n.cli_control/\n"
507
+ out, added = _add_gitignore_entries(text, [".cli_control/"])
508
+ assert added == []
509
+ assert out == text
510
+
511
+
512
+ @pytest.mark.parametrize(
513
+ "existing",
514
+ [".cli_control\n", "/.cli_control/\n", "/.cli_control\n", ".cli_control/ \n"],
515
+ )
516
+ def test_gitignore_idempotent_on_equivalent_variants(existing: str) -> None:
517
+ """带/不带斜杠、带前导 / 、带尾随空白都视为已忽略,不重复加。"""
518
+ from godot_cli_control.init_cmd import _add_gitignore_entries
519
+
520
+ out, added = _add_gitignore_entries(existing, [".cli_control/"])
521
+ assert added == []
522
+ assert out == existing
523
+
524
+
525
+ def test_gitignore_commented_line_does_not_count_as_present() -> None:
526
+ """被注释掉的同名行不算忽略,真条目仍要补上。"""
527
+ from godot_cli_control.init_cmd import _add_gitignore_entries
528
+
529
+ text = "# .cli_control/\n"
530
+ out, added = _add_gitignore_entries(text, [".cli_control/"])
531
+ assert added == [".cli_control/"]
532
+ assert "# .cli_control/" in out # 注释保留
533
+ assert out.endswith(".cli_control/\n")
534
+
535
+
536
+ def test_gitignore_appends_newline_when_file_lacks_trailing_one() -> None:
537
+ """原文件末行无换行符时,新条目必须独立成行、不黏在末行后面。"""
538
+ from godot_cli_control.init_cmd import _add_gitignore_entries
539
+
540
+ out, added = _add_gitignore_entries("build/", [".cli_control/"])
541
+ assert added == [".cli_control/"]
542
+ assert out == "build/\n.cli_control/\n"
543
+
544
+
545
+ def test_ensure_gitignore_creates_file_when_missing(tmp_path: Path) -> None:
546
+ """项目根没有 .gitignore → 创建并写入 .cli_control/。"""
547
+ from godot_cli_control.init_cmd import _ensure_gitignore_entries
548
+
549
+ added = _ensure_gitignore_entries(tmp_path)
550
+ gi = tmp_path / ".gitignore"
551
+ assert gi.is_file()
552
+ assert ".cli_control/" in gi.read_text(encoding="utf-8")
553
+ assert added == [".cli_control/"]
554
+
555
+
556
+ def test_ensure_gitignore_idempotent_across_runs(tmp_path: Path) -> None:
557
+ """连跑两次只写一条,不重复。"""
558
+ from godot_cli_control.init_cmd import _ensure_gitignore_entries
559
+
560
+ _ensure_gitignore_entries(tmp_path)
561
+ added2 = _ensure_gitignore_entries(tmp_path)
562
+ assert added2 == []
563
+ content = (tmp_path / ".gitignore").read_text(encoding="utf-8")
564
+ assert content.count(".cli_control/") == 1
565
+
566
+
567
+ def test_ensure_gitignore_preserves_crlf(tmp_path: Path) -> None:
568
+ """已有 CRLF 的 .gitignore 在追加后必须仍是 CRLF,不混入裸 LF。"""
569
+ from godot_cli_control.init_cmd import _ensure_gitignore_entries
570
+
571
+ gi = tmp_path / ".gitignore"
572
+ gi.write_bytes(b"*.tmp\r\nbuild/\r\n")
573
+ _ensure_gitignore_entries(tmp_path)
574
+ after = gi.read_bytes()
575
+ assert b".cli_control/\r\n" in after
576
+ bare_lf = after.count(b"\n") - after.count(b"\r\n")
577
+ assert bare_lf == 0, f"出现裸 LF:{bare_lf} 处"
578
+
579
+
580
+ def test_run_init_adds_cli_control_to_gitignore(
581
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
582
+ ) -> None:
583
+ """端到端:run_init 默认在项目根 .gitignore 忽略 .cli_control/。"""
584
+ monkeypatch.delenv("GODOT_BIN", raising=False)
585
+ monkeypatch.setattr(
586
+ "godot_cli_control.init_cmd.find_godot_binary", lambda: None
587
+ )
588
+ proj = _minimal_project(tmp_path)
589
+ assert run_init(proj) == 0
590
+ gi = (proj / ".gitignore").read_text(encoding="utf-8")
591
+ assert ".cli_control/" in gi
592
+
593
+
594
+ def test_run_init_no_gitignore_skips_gitignore(
595
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
596
+ ) -> None:
597
+ """write_gitignore=False → 不创建 / 不改 .gitignore。"""
598
+ monkeypatch.delenv("GODOT_BIN", raising=False)
599
+ monkeypatch.setattr(
600
+ "godot_cli_control.init_cmd.find_godot_binary", lambda: None
601
+ )
602
+ proj = _minimal_project(tmp_path)
603
+ assert run_init(proj, write_gitignore=False) == 0
604
+ assert not (proj / ".gitignore").exists()
605
+
606
+
607
+ def test_run_init_skills_only_skips_gitignore(tmp_path: Path) -> None:
608
+ """skills_only 属于纯刷 SKILL.md,不应碰 .gitignore。"""
609
+ proj = _make_min_godot_project(tmp_path)
610
+ assert run_init(proj, skills_only=True) == 0
611
+ assert not (proj / ".gitignore").exists()
612
+
613
+
614
+ def test_run_init_json_records_gitignore_added(
615
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
616
+ ) -> None:
617
+ """json 模式 result 必须回填 gitignore_added 字段。"""
618
+ monkeypatch.delenv("GODOT_BIN", raising=False)
619
+ monkeypatch.setattr(
620
+ "godot_cli_control.init_cmd.find_godot_binary", lambda: None
621
+ )
622
+ proj = _make_min_godot_project(tmp_path)
623
+ result: dict[str, object] = {}
624
+ assert run_init(proj, output_format="json", result=result) == 0
625
+ assert result["gitignore_added"] == [".cli_control/"]
626
+
627
+
628
+ def test_cmd_init_no_gitignore_flag_skips(
629
+ tmp_path: Path,
630
+ monkeypatch: pytest.MonkeyPatch,
631
+ capsys: pytest.CaptureFixture[str],
632
+ ) -> None:
633
+ """CLI 层:--no-gitignore(ns.no_gitignore=True)不写 .gitignore。"""
634
+ import argparse
635
+
636
+ from godot_cli_control.cli import OUTPUT_JSON, cmd_init
637
+
638
+ monkeypatch.delenv("GODOT_BIN", raising=False)
639
+ monkeypatch.setattr(
640
+ "godot_cli_control.init_cmd.find_godot_binary", lambda: None
641
+ )
642
+ proj = _make_min_godot_project(tmp_path)
643
+ ns = argparse.Namespace(
644
+ path=str(proj),
645
+ force=False,
646
+ no_skills=False,
647
+ skills_only=False,
648
+ skills_no_clobber=False,
649
+ no_gitignore=True,
650
+ output_format=OUTPUT_JSON,
651
+ )
652
+ assert cmd_init(ns) == 0
653
+ capsys.readouterr()
654
+ assert not (proj / ".gitignore").exists()