lcode-cli 0.25.0__tar.gz → 0.26.0__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 (158) hide show
  1. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/CHANGELOG.md +15 -1
  2. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/automation.md +2 -0
  4. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/configuration.md +1 -0
  5. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/quickstart.md +1 -1
  6. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/usage.md +33 -0
  7. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/__init__.py +1 -1
  8. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/action.py +8 -1
  9. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/agent.py +1 -0
  10. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/api.py +1 -0
  11. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/config.py +1 -0
  12. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/gitflow.py +15 -0
  13. lcode_cli-0.26.0/src/lcode/secretscan.py +315 -0
  14. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/tools.py +25 -2
  15. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/conftest.py +40 -0
  16. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_gitflow.py +1 -40
  17. lcode_cli-0.26.0/tests/test_secretscan.py +163 -0
  18. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.editorconfig +0 -0
  19. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.github/CODEOWNERS +0 -0
  20. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  21. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  22. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  23. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  24. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  25. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.github/dependabot.yml +0 -0
  26. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.github/workflows/ci.yml +0 -0
  27. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.github/workflows/docs.yml +0 -0
  28. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.github/workflows/release.yml +0 -0
  29. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.gitignore +0 -0
  30. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/.pre-commit-config.yaml +0 -0
  31. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/AGENTS.md +0 -0
  32. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/CODE_OF_CONDUCT.md +0 -0
  33. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/CONTRIBUTING.md +0 -0
  34. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/LICENSE +0 -0
  35. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/README.md +0 -0
  36. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/SECURITY.md +0 -0
  37. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/action.yml +0 -0
  38. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/agents.md +0 -0
  39. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/assets/demo.svg +0 -0
  40. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/assets/extra.css +0 -0
  41. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/assets/logo.svg +0 -0
  42. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/assets/social-preview.png +0 -0
  43. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/bench.md +0 -0
  44. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/changelog.md +0 -0
  45. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/commands.md +0 -0
  46. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/contributing.md +0 -0
  47. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/editors.md +0 -0
  48. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/git.md +0 -0
  49. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/hooks.md +0 -0
  50. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/how-it-works.md +0 -0
  51. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/index.md +0 -0
  52. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/installation.md +0 -0
  53. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/lsp.md +0 -0
  54. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/mcp.md +0 -0
  55. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/memory.md +0 -0
  56. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/models.md +0 -0
  57. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/sandbox.md +0 -0
  58. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/search.md +0 -0
  59. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/servers.md +0 -0
  60. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/docs/troubleshooting.md +0 -0
  61. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/examples/README.md +0 -0
  62. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/examples/api/fix_tests.py +0 -0
  63. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/examples/commands/review.md +0 -0
  64. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/examples/skills/commit-message/SKILL.md +0 -0
  65. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  66. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/install.sh +0 -0
  67. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/mkdocs.yml +0 -0
  68. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/overrides/main.html +0 -0
  69. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/pyproject.toml +0 -0
  70. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/scripts/record_demo.py +0 -0
  71. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/scripts/social-preview.html +0 -0
  72. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/__main__.py +0 -0
  73. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/acp.py +0 -0
  74. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/backends.py +0 -0
  75. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/bench.py +0 -0
  76. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/bench_backend.py +0 -0
  77. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/bench_runtime.py +0 -0
  78. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/catalog.py +0 -0
  79. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/checkpoints.py +0 -0
  80. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/cli.py +0 -0
  81. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/clipboard.py +0 -0
  82. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/codesearch.py +0 -0
  83. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/context.py +0 -0
  84. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/extensions.py +0 -0
  85. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/frontmatter.py +0 -0
  86. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/hardware.py +0 -0
  87. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/hooks.py +0 -0
  88. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/jobs.py +0 -0
  89. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/limits.py +0 -0
  90. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/lsp.py +0 -0
  91. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/__init__.py +0 -0
  92. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/auth.py +0 -0
  93. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/catalog.py +0 -0
  94. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/catalog.toml +0 -0
  95. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/client.py +0 -0
  96. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/commands.py +0 -0
  97. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/config.py +0 -0
  98. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/http.py +0 -0
  99. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/manager.py +0 -0
  100. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/protocol.py +0 -0
  101. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/server.py +0 -0
  102. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/mcp/stdio.py +0 -0
  103. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/memory.py +0 -0
  104. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/models.toml +0 -0
  105. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/notify.py +0 -0
  106. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/ollama.py +0 -0
  107. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/permissions.py +0 -0
  108. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/planning.py +0 -0
  109. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/render.py +0 -0
  110. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/repeats.py +0 -0
  111. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/repl.py +0 -0
  112. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/repomap.py +0 -0
  113. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/sandbox.py +0 -0
  114. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/servers/__init__.py +0 -0
  115. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/servers/encord.py +0 -0
  116. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/servers/valohai.py +0 -0
  117. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/servers/xai.py +0 -0
  118. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/sessions.py +0 -0
  119. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/subagents.py +0 -0
  120. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/update.py +0 -0
  121. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/vision.py +0 -0
  122. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/src/lcode/web.py +0 -0
  123. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/fake_lsp_server.py +0 -0
  124. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/fake_mcp_server.py +0 -0
  125. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_acp.py +0 -0
  126. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_action.py +0 -0
  127. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_agent.py +0 -0
  128. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_api.py +0 -0
  129. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_backends.py +0 -0
  130. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_bench.py +0 -0
  131. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_catalog.py +0 -0
  132. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_checkpoints.py +0 -0
  133. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_cli.py +0 -0
  134. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_clipboard.py +0 -0
  135. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_codesearch.py +0 -0
  136. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_config.py +0 -0
  137. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_context.py +0 -0
  138. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_context_picker.py +0 -0
  139. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_extensions.py +0 -0
  140. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_hooks.py +0 -0
  141. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_jobs.py +0 -0
  142. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_lsp.py +0 -0
  143. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_mcp.py +0 -0
  144. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_memory.py +0 -0
  145. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_permissions.py +0 -0
  146. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_plan_mode.py +0 -0
  147. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_repeats.py +0 -0
  148. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_repomap.py +0 -0
  149. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_sandbox.py +0 -0
  150. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_servers.py +0 -0
  151. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_sessions.py +0 -0
  152. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_subagents.py +0 -0
  153. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_tools.py +0 -0
  154. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_update.py +0 -0
  155. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_vision.py +0 -0
  156. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_web.py +0 -0
  157. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/tests/test_xai.py +0 -0
  158. {lcode_cli-0.25.0 → lcode_cli-0.26.0}/uv.lock +0 -0
@@ -6,6 +6,19 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.26.0] - 2026-10-10
10
+
11
+ ### Added
12
+
13
+ - lcode checks for secrets before the model runs `git commit` or `git push`: passwords, keys and
14
+ tokens in code or configuration, credentials in URLs (`rtsp://user:pass@…`), private keys,
15
+ tokens with a known form, and `.env` files, in the lines a commit adds or the commits a push
16
+ sends. In `ask` mode you decide; in `auto-edit` and `yolo` the command doesn't run, and the model
17
+ is told to move the secret out of the code. `/commit` and `/pr` show the findings before you
18
+ confirm, and the GitHub Action doesn't commit at all. `lcode: allow-secret` in a comment allows a
19
+ line; `secret_check = false` turns the check off. See
20
+ [Secrets in commits](https://nasser1941.github.io/lcode/usage/#secrets-in-commits).
21
+
9
22
  ## [0.25.0] - 2026-10-10
10
23
 
11
24
  ### Changed
@@ -540,7 +553,8 @@ First public release.
540
553
  - `AGENTS.md` project instructions and `/init` to generate them.
541
554
  - One-line installer for Linux and macOS.
542
555
 
543
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.25.0...HEAD
556
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.26.0...HEAD
557
+ [0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
544
558
  [0.25.0]: https://github.com/nasser1941/lcode/compare/v0.24.0...v0.25.0
545
559
  [0.24.0]: https://github.com/nasser1941/lcode/compare/v0.23.0...v0.24.0
546
560
  [0.23.0]: https://github.com/nasser1941/lcode/compare/v0.22.0...v0.23.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.25.0
3
+ Version: 0.26.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -90,6 +90,8 @@ with Session("path/to/repo", permission_mode="auto-edit") as session:
90
90
  - **Events while it runs:** `session.stream(prompt)` yields the events above, ending with the result.
91
91
  - **Permissions:** pass `approve` to decide permission requests. It gets
92
92
  `{"kind": "bash", "title": "…", "target": "pytest -q"}`; without it, every request is refused.
93
+ A `git commit` or `git push` that would add [what looks like a secret](usage.md#secrets-in-commits)
94
+ comes as `{"kind": "secret", …}` in `ask` mode, and doesn't run in the other modes.
93
95
  - **Options:** `model`, `context`, `permission_mode`, `allowed_tools`, `max_steps`, `think`,
94
96
  `web`, `memory`, `mcp` and `sandbox`. Leave one out to use your
95
97
  [configuration](configuration.md).
@@ -43,6 +43,7 @@ lcode config path # print the file location
43
43
  | `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
44
44
  | `notify_after` | `30` | Seconds a request runs before it notifies |
45
45
  | `repeat_limit` | `3` | Identical tool calls with identical results before lcode [steps in](how-it-works.md#when-the-model-repeats-itself); `0` turns it off |
46
+ | `secret_check` | `true` | Before `git commit` and `git push`, look for passwords, keys and tokens they'd add ([details](usage.md#secrets-in-commits)) |
46
47
  | `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
47
48
 
48
49
  Besides these settings, `config.toml` can hold [hooks](hooks.md) (`[[hooks]]`) and
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.25.0 — local coding agent │
15
+ │ lcode v0.26.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -213,6 +213,39 @@ deny lists, per user or per repository; deny always wins, even in `yolo`. Text a
213
213
  With the [sandbox](sandbox.md) on, `auto-edit` mode runs shell commands without asking too: they can
214
214
  only reach the project, and `/undo` can take their changes back.
215
215
 
216
+ ### Secrets in commits
217
+
218
+ Before the model runs `git commit` or `git push`, lcode checks what would go into the repository's
219
+ history: the lines a commit adds (including files a `git add` in the same command stages), or every
220
+ commit a push would send. It looks for:
221
+
222
+ - passwords, keys and tokens written as values in code or configuration, like
223
+ `"password": "…"` or `API_KEY=…`;
224
+ - credentials in addresses, like `rtsp://admin:…@192.168.1.10`;
225
+ - private keys and tokens with a known form (GitHub, GitLab, AWS, Slack, OpenAI, Anthropic, xAI,
226
+ Google, Hugging Face, Stripe);
227
+ - `.env` files.
228
+
229
+ Placeholders such as `CHANGE_ME`, `your_password`, `${PASSWORD}` or `os.environ["PASSWORD"]` don't
230
+ count.
231
+
232
+ ```text
233
+ This commit would add what looks like a secret to the repository's history:
234
+ - camera_wall.py:42: a password or key: "password": "Xk…"
235
+ ```
236
+
237
+ - **In `ask` mode,** you decide: lcode shows the findings and the command.
238
+ - **In `auto-edit` and `yolo`,** the command doesn't run. The model is told to read the secret from
239
+ an environment variable or a configuration file that git ignores, and to ask you if it isn't one.
240
+ - **`/commit` and `/pr`** show the same findings before you confirm.
241
+ - **The [GitHub Action](automation.md#github-action)** doesn't commit or push at all when it finds
242
+ one.
243
+
244
+ If a line only looks like a secret, add `lcode: allow-secret` (or `gitleaks:allow`) in a comment
245
+ on it. `lcode config set secret_check false` turns the check off. It's a safety net, not a full
246
+ scanner: for a public repository, also run a tool such as [gitleaks](https://github.com/gitleaks/gitleaks)
247
+ before you push.
248
+
216
249
  ## Plan mode
217
250
 
218
251
  Local models are slower than cloud models and make more mistakes, so on a bigger change it pays to
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.25.0"
6
+ __version__ = "0.26.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -302,7 +302,14 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
302
302
  reply = result.text.strip() or "(no answer)"
303
303
  changed = git(cwd, "status", "--porcelain").strip()
304
304
  if changed:
305
- from lcode import gitflow
305
+ from lcode import gitflow, secretscan
306
+
307
+ found = secretscan.in_files(cwd, ["."], staged=False)
308
+ if found:
309
+ raise ActionError(
310
+ secretscan.report("commit", found, excerpts=False) + "\n\nSo lcode didn't commit or push its "
311
+ "changes. Secrets belong in the repository's secrets or in configuration that git ignores."
312
+ )
306
313
 
307
314
  data = gitflow.ask(
308
315
  session.agent,
@@ -233,6 +233,7 @@ class Settings:
233
233
  notify: bool = False # desktop notifications after long requests (lcode.notify)
234
234
  notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
235
235
  max_parallel_agents: int = 1
236
+ secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
236
237
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
237
238
 
238
239
 
@@ -194,6 +194,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
194
194
  embed_model=cfg["embed_model"],
195
195
  notify=cfg["notify"],
196
196
  notify_after=cfg["notify_after"],
197
+ secret_check=cfg["secret_check"],
197
198
  repeat_limit=cfg["repeat_limit"],
198
199
  )
199
200
  agent = Agent(ollama, settings, cwd, console=console)
@@ -60,6 +60,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
60
60
  "notify": (True, bool, "desktop notification when a long request is done or waits for you"),
61
61
  "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
62
62
  "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
63
+ "secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
63
64
  "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
64
65
  }
65
66
  ENV_OVERRIDES = {
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING
24
24
 
25
25
  from rich.markup import escape
26
26
  from rich.panel import Panel
27
+ from rich.text import Text
27
28
 
28
29
  if TYPE_CHECKING:
29
30
  from lcode.agent import Agent
@@ -316,6 +317,9 @@ def commit_command(agent: Agent, hint: str = "") -> None:
316
317
  chosen = [f for f in answer.get("files") or [] if f in paths]
317
318
  files = chosen or paths
318
319
  left_out = [p for p in paths if p not in files]
320
+ from lcode import secretscan
321
+
322
+ secrets_found = secretscan.in_files(root, files, bool(staged)) if agent.settings.secret_check else []
319
323
  while True:
320
324
  c.print(Panel(escape(message), title="Commit message", title_align="left", border_style="cyan"))
321
325
  c.print(f" [bold]Files[/] {escape(', '.join(files))}")
@@ -323,6 +327,9 @@ def commit_command(agent: Agent, hint: str = "") -> None:
323
327
  c.print(f" [dim]Left out (unrelated, the model thinks): {escape(', '.join(left_out))}[/]")
324
328
  if risky:
325
329
  c.print(f" [dim]Left out (may hold secrets; stage them yourself if needed): {escape(', '.join(risky))}[/]")
330
+ if secrets_found:
331
+ c.print(Text(secretscan.report("commit", secrets_found), style="red"))
332
+ c.print(" [dim]Commit them only if they aren't secrets; otherwise move them out of the code first.[/]")
326
333
  choice = confirm(agent, " [y] commit · [e] edit the message · [n] cancel: ", message)
327
334
  if choice == "e":
328
335
  from lcode.planning import edit_text
@@ -552,6 +559,14 @@ def pr_command(agent: Agent, arg: str = "") -> None:
552
559
 
553
560
 
554
561
  def push(agent: Agent, root: Path, remote: str, branch: str) -> bool:
562
+ from lcode import secretscan
563
+
564
+ found = secretscan.outgoing(root, branch) if agent.settings.secret_check else []
565
+ if found:
566
+ agent.console.print(Text(secretscan.report("push", found), style="red"))
567
+ if confirm(agent, " Push anyway? [y]es / [n]o: ") != "y":
568
+ agent.console.print("Not pushed.")
569
+ return False
555
570
  try:
556
571
  with agent.console.status(f"Pushing {escape(branch)}…", spinner="dots"):
557
572
  git(root, "push", "-q", "-u", remote, branch, timeout=300)
@@ -0,0 +1,315 @@
1
+ """Stop secrets before they're committed or pushed.
2
+
3
+ Before the model runs `git commit` or `git push`, lcode scans what that would add to the repository's
4
+ history: the lines a commit adds (including files a `git add` in the same command stages), or every commit
5
+ a push would send. Passwords and tokens in code, private keys, credentials in URLs (`rtsp://user:pass@…`)
6
+ and `.env` files are reported with their file and line. In `ask` mode the user decides; in the other modes
7
+ the command doesn't run, and the model is told to move the secret out of the code.
8
+
9
+ A line that holds a value which only looks like a secret can carry `lcode: allow-secret` (or
10
+ `gitleaks:allow`) in a comment, and is skipped.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import shlex
17
+ import subprocess
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from lcode.memory import SECRET_PATTERNS
22
+
23
+ ALLOW = ("lcode: allow-secret", "lcode:allow-secret", "gitleaks:allow")
24
+ MAX_FINDINGS = 10
25
+ GIT_TIMEOUT = 20
26
+ MAX_FILE = 1_000_000 # bytes of an untracked file that are scanned
27
+
28
+ # Values that only stand in for a secret.
29
+ PLACEHOLDER = re.compile(
30
+ r"^(?:change[_-]?me\w*|your[_-]?\w*|x+|\*+|\.+|…+|<[^>]*>"
31
+ r"|(?:dummy|example|placeholder|redacted|secret|password|passwd|pass|pwd|token|test|changeit|none|null"
32
+ r"|true|false|required|optional|hidden|masked)(?:[_-]?here)?)$",
33
+ re.I,
34
+ )
35
+ # Words that only appear in made-up values: "change-me-in-production", "test-token", "example-key"…
36
+ FAKE_WORDS = (
37
+ "example",
38
+ "sample",
39
+ "dummy",
40
+ "fake",
41
+ "changeme",
42
+ "change-me",
43
+ "change_me",
44
+ "placeholder",
45
+ "redacted",
46
+ "test",
47
+ )
48
+ # A password, key or token written in code or configuration as a literal value.
49
+ ASSIGNED = re.compile(
50
+ r"""(?P<name>\b[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key"""
51
+ r"""|client[_-]?secret)[\w.-]*)['"]?\s*(?::|=|:=|=>)\s*(?P<quote>['"]?)(?P<value>[^\s'"`,;)}\]]{6,})"""
52
+ r"""(?P=quote)""",
53
+ re.I,
54
+ )
55
+ # Names that hold something about a secret (where it is, how it looks), not the secret itself.
56
+ ABOUT = re.compile(
57
+ r"(?:url|uri|endpoint|file|path|dir|env|var|variable|name|field|header|type|label|prompt|hint|length|len|min|"
58
+ r"max|policy|pattern|regex|format|count|expires?|expiry|ttl|timeout|limit|required|enabled|hash|salt_rounds)$",
59
+ re.I,
60
+ )
61
+ EXTRA = [
62
+ ("an xAI API key", re.compile(r"\bxai-[A-Za-z0-9]{20,}")),
63
+ ("a Hugging Face token", re.compile(r"\bhf_[A-Za-z0-9]{30,}")),
64
+ ("a Stripe key", re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}")),
65
+ ]
66
+ CONFIG_FILES = re.compile(r"(?:\.(?:env|ini|cfg|conf|config|properties|ya?ml|toml)|(?:^|/)\.env[\w.-]*)$", re.I)
67
+ URL_PASSWORD = re.compile(r"\b[a-z][a-z0-9+.\-]*://(?P<user>[^\s/:@'\"]+):(?P<value>[^\s/@'\"]+)@", re.I)
68
+ STRONG = [p for p in SECRET_PATTERNS if p[0] not in ("a password in a URL", "a password or key")] + EXTRA
69
+ ENV_FILE = re.compile(r"(?:^|/)\.env(?:\.[\w-]+)?$")
70
+ ENV_EXAMPLES = re.compile(r"\.(?:example|sample|template|dist|defaults?)$")
71
+
72
+
73
+ @dataclass
74
+ class Finding:
75
+ path: str
76
+ line: int # 0: the whole file
77
+ label: str # what it looks like, e.g. "a password"
78
+ excerpt: str # the line, with the secret masked
79
+ commit: str = "" # for a push: the commit that adds it
80
+
81
+ def describe(self, excerpt: bool = True) -> str:
82
+ where = f"{self.path}:{self.line}" if self.line else self.path
83
+ if self.commit:
84
+ where += f" (commit {self.commit})"
85
+ return f"{where}: {self.label}" + (f": {self.excerpt}" if self.excerpt and excerpt else "")
86
+
87
+
88
+ def mask(value: str) -> str:
89
+ return value[:2] + "…" if len(value) > 4 else "…"
90
+
91
+
92
+ def placeholder(value: str) -> bool:
93
+ """A value that stands in for a secret: CHANGE_ME, <token>, ${PASSWORD}, a path, a web address…"""
94
+ if PLACEHOLDER.match(value) or any(c in value for c in "{}$%<>"):
95
+ return True
96
+ if any(word in value.lower() for word in FAKE_WORDS):
97
+ return True
98
+ return value.startswith(("/", "./", "~/", "http:", "https:", "os.", "process.env", "env."))
99
+
100
+
101
+ def scan_line(text: str, config: bool = False) -> tuple[str, str] | None:
102
+ """What kind of secret the line holds, and the line with it masked. In configuration files (`config`) a
103
+ value counts without quotes too, as in `PASSWORD=hunter22` or `password: hunter22`."""
104
+ if any(marker in text for marker in ALLOW):
105
+ return None
106
+ shown = text.strip()[:160]
107
+ for label, pattern in STRONG:
108
+ found = pattern.search(text)
109
+ if found:
110
+ return label, shown.replace(found.group(0), mask(found.group(0)))
111
+ found = URL_PASSWORD.search(text)
112
+ if found and not placeholder(found.group("value")) and not placeholder(found.group("user")):
113
+ return "a password in a URL", shown.replace(found.group("value"), mask(found.group("value")))
114
+ for found in ASSIGNED.finditer(text):
115
+ value = found.group("value")
116
+ if placeholder(value) or ABOUT.search(found.group("name")):
117
+ continue
118
+ if not found.group("quote") and not (config and re.match(r"^\s*(?:export\s+)?[\w.-]+\s*[:=]", text)):
119
+ continue # in code, only a quoted literal is a value: `password = args.password` isn't one
120
+ return "a password or key", shown.replace(value, mask(value))
121
+ return None
122
+
123
+
124
+ def scan_file(path: str, lines: list[tuple[int, str]], commit: str = "") -> list[Finding]:
125
+ if ENV_FILE.search(path) and not ENV_EXAMPLES.search(path):
126
+ return [Finding(path, 0, "an environment file (.env), which usually holds secrets", "", commit)]
127
+ findings = []
128
+ config = bool(CONFIG_FILES.search(path))
129
+ for number, text in lines:
130
+ hit = scan_line(text, config)
131
+ if hit:
132
+ findings.append(Finding(path, number, hit[0], hit[1], commit))
133
+ return findings
134
+
135
+
136
+ def scan_diff(diff: str) -> list[Finding]:
137
+ """The secrets in the lines a unified diff adds (`git diff`, or `git log -p` with commit: lines)."""
138
+ findings: list[Finding] = []
139
+ path, number, commit = "", 0, ""
140
+ added: list[tuple[int, str]] = []
141
+
142
+ def flush() -> None:
143
+ if path and (added or ENV_FILE.search(path)):
144
+ findings.extend(scan_file(path, added, commit))
145
+ added.clear()
146
+
147
+ for line in diff.splitlines():
148
+ if line.startswith("commit:"):
149
+ flush()
150
+ commit, path = line.split(":", 1)[1].strip(), ""
151
+ elif line.startswith("+++ "):
152
+ flush()
153
+ target = line[4:].strip()
154
+ path = "" if target == "/dev/null" else target.removeprefix("b/")
155
+ elif line.startswith("@@"):
156
+ hunk = re.search(r"\+(\d+)", line)
157
+ number = int(hunk.group(1)) if hunk else 0
158
+ elif line.startswith("+") and path:
159
+ added.append((number, line[1:]))
160
+ number += 1
161
+ elif line.startswith(" "):
162
+ number += 1
163
+ flush()
164
+ return findings
165
+
166
+
167
+ def git(cwd: Path, *args: str) -> str:
168
+ try:
169
+ done = subprocess.run(
170
+ ["git", *args], cwd=cwd, capture_output=True, text=True, errors="replace", timeout=GIT_TIMEOUT
171
+ )
172
+ except (OSError, subprocess.SubprocessError):
173
+ return ""
174
+ return done.stdout if done.returncode == 0 else ""
175
+
176
+
177
+ GIT_VERB = re.compile(r"\bgit\b(?:\s+-[-\w=.]+(?:\s+[^\s'\"-]\S*)?)*\s+(commit|push)\b")
178
+
179
+
180
+ @dataclass
181
+ class GitSteps:
182
+ adds: list[list[str]] # the arguments of each `git add`
183
+ commit: list[str] | None # the arguments of `git commit`
184
+ push: list[str] | None # the arguments of `git push`
185
+
186
+
187
+ def git_steps(command: str) -> GitSteps | None:
188
+ """The git add, commit and push a shell command runs, or None when it commits and pushes nothing."""
189
+ steps = GitSteps([], None, None)
190
+ for segment in re.split(r"&&|\|\||;|\n|\|", command):
191
+ try:
192
+ words = shlex.split(segment)
193
+ except ValueError:
194
+ words = segment.split()
195
+ while words and re.fullmatch(r"\w+=\S*", words[0]):
196
+ words = words[1:] # VAR=value in front
197
+ if len(words) < 2 or words[0] != "git":
198
+ continue
199
+ i = 1
200
+ while i < len(words) and words[i].startswith("-"):
201
+ i += 2 if words[i] in ("-C", "-c") else 1 # git -C dir / -c key=value
202
+ if i >= len(words):
203
+ continue
204
+ verb, rest = words[i].strip("'\""), words[i + 1 :]
205
+ if verb == "add":
206
+ steps.adds.append(rest)
207
+ elif verb == "commit":
208
+ steps.commit = rest
209
+ elif verb == "push":
210
+ steps.push = rest
211
+ # Inside quotes, as in bash -c "git add . && git commit …", sh -c or eval, what gets staged can't be told
212
+ # from the words: assume everything.
213
+ nested = [verb for m in re.finditer(r"(['\"])(.*?)\1", command, re.S) for verb in GIT_VERB.findall(m.group(2))]
214
+ if steps.commit is None and steps.push is None:
215
+ nested += GIT_VERB.findall(command)
216
+ if "commit" in nested:
217
+ steps.commit, steps.adds = ["--all"], [["."]]
218
+ if "push" in nested and steps.push is None:
219
+ steps.push = []
220
+ return steps if steps.commit is not None or steps.push is not None else None
221
+
222
+
223
+ def untracked(cwd: Path, paths: list[str]) -> list[Finding]:
224
+ findings = []
225
+ for name in git(cwd, "ls-files", "--others", "--exclude-standard", "--", *paths).splitlines():
226
+ file = cwd / name
227
+ try:
228
+ if file.stat().st_size > MAX_FILE:
229
+ continue
230
+ text = file.read_text(errors="strict")
231
+ except (OSError, UnicodeDecodeError):
232
+ continue # binary or unreadable
233
+ findings.extend(scan_file(name, list(enumerate(text.splitlines(), 1))))
234
+ return findings
235
+
236
+
237
+ def to_commit(cwd: Path, steps: GitSteps) -> list[Finding]:
238
+ """What `git add` and `git commit` in the command would commit: the staged changes, plus what it stages."""
239
+ args = steps.commit or []
240
+ everything = any(a in ("-a", "--all") or re.fullmatch(r"-[a-zA-Z]*a[a-zA-Z]*", a) for a in args if a != "--amend")
241
+ findings = scan_diff(git(cwd, "diff", "--cached", "--no-color", "--no-ext-diff", "-U0"))
242
+ if everything:
243
+ findings += scan_diff(git(cwd, "diff", "--no-color", "--no-ext-diff", "-U0"))
244
+ for add in steps.adds:
245
+ options = [a for a in add if a.startswith("-")]
246
+ paths = [a for a in add if not a.startswith("-")] or (["."] if {"-A", "--all"} & set(options) else [])
247
+ if not paths and "-u" not in options and "--update" not in options:
248
+ continue
249
+ paths = paths or ["."]
250
+ findings += scan_diff(git(cwd, "diff", "--no-color", "--no-ext-diff", "-U0", "--", *paths))
251
+ if "-u" not in options and "--update" not in options:
252
+ findings += untracked(cwd, paths)
253
+ return findings
254
+
255
+
256
+ def to_push(cwd: Path, steps: GitSteps) -> list[Finding]:
257
+ """The secrets in the commits a push would send that no remote has yet."""
258
+ refs = [a for a in (steps.push or []) if not a.startswith("-")]
259
+ branches = [r.split(":", 1)[0].lstrip("+") for r in refs[1:]] or ["HEAD"]
260
+ findings: list[Finding] = []
261
+ for branch in branches:
262
+ if not branch or not git(cwd, "rev-parse", "--verify", "--quiet", f"{branch}^{{commit}}").strip():
263
+ continue
264
+ log = git(
265
+ cwd, "log", "-p", "--no-color", "--no-ext-diff", "-U0", "--format=commit:%h", branch, "--not", "--remotes"
266
+ )
267
+ findings += scan_diff(log)
268
+ return findings
269
+
270
+
271
+ def check(cwd: Path, command: str) -> tuple[str, list[Finding]]:
272
+ """('commit' or 'push', the secrets it would add to the history) for a shell command."""
273
+ steps = git_steps(command)
274
+ if steps is None or not git(cwd, "rev-parse", "--git-dir").strip():
275
+ return "", []
276
+ findings: list[Finding] = []
277
+ if steps.commit is not None:
278
+ findings += to_commit(cwd, steps)
279
+ if steps.push is not None:
280
+ findings += to_push(cwd, steps)
281
+ unique = list({(f.path, f.line, f.label): f for f in findings}.values())
282
+ return ("push" if steps.push is not None and steps.commit is None else "commit"), unique
283
+
284
+
285
+ def in_files(cwd: Path, paths: list[str], staged: bool) -> list[Finding]:
286
+ """For lcode's /commit: the secrets in the staged changes, or in the changes to these files."""
287
+ if staged:
288
+ return scan_diff(git(cwd, "diff", "--cached", "--no-color", "--no-ext-diff", "-U0"))
289
+ if not paths:
290
+ return []
291
+ diffs = [
292
+ git(cwd, "diff", *staged_flag, "--no-color", "--no-ext-diff", "-U0", "--", *paths)
293
+ for staged_flag in ([], ["--cached"])
294
+ ]
295
+ return scan_diff("\n".join(diffs)) + untracked(cwd, paths)
296
+
297
+
298
+ def outgoing(cwd: Path, branch: str = "HEAD") -> list[Finding]:
299
+ """For lcode's /pr: the secrets in the commits of `branch` that no remote has yet."""
300
+ return to_push(cwd, GitSteps([], None, ["origin", branch]))
301
+
302
+
303
+ def report(action: str, findings: list[Finding], excerpts: bool = True) -> str:
304
+ """The findings, one per line; without `excerpts` for places others read, like a public comment."""
305
+ lines = [f"- {f.describe(excerpts)}" for f in findings[:MAX_FINDINGS]]
306
+ if len(findings) > MAX_FINDINGS:
307
+ lines.append(f"- … and {len(findings) - MAX_FINDINGS} more")
308
+ return f"This {action} would add what looks like a secret to the repository's history:\n" + "\n".join(lines)
309
+
310
+
311
+ ADVICE = (
312
+ "lcode didn't run the command. Don't commit secrets: read them from an environment variable or from a "
313
+ "configuration file that git ignores (and commit an example with placeholders instead). If one of these "
314
+ "isn't a secret, tell the user: they decide."
315
+ )
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import difflib
6
6
  import fnmatch
7
+ import hashlib
7
8
  import inspect
8
9
  import json
9
10
  import os
@@ -19,11 +20,12 @@ from collections.abc import Callable
19
20
  from pathlib import Path
20
21
  from typing import TYPE_CHECKING
21
22
 
23
+ from rich.console import Group
22
24
  from rich.panel import Panel
23
25
  from rich.syntax import Syntax
24
26
  from rich.text import Text
25
27
 
26
- from lcode import web
28
+ from lcode import secretscan, web
27
29
  from lcode.context import shorten
28
30
  from lcode.jobs import JobError, describe, stop_leftovers
29
31
  from lcode.permissions import bash_key, is_read_only
@@ -823,7 +825,28 @@ class Toolbox:
823
825
  verdict = self.agent.perms.rule("bash", command)
824
826
  if verdict is not None and not verdict[0]: # deny rules apply to read-only commands too
825
827
  return verdict[1]
826
- if not is_read_only(command) and not (sandbox and self.agent.perms.mode == "auto-edit") and verdict is None:
828
+ approved = False
829
+ if self.agent.settings.secret_check and not is_read_only(command):
830
+ action, found = secretscan.check(self.agent.cwd, command)
831
+ if found:
832
+ text = secretscan.report(action, found)
833
+ if self.agent.perms.mode != "ask": # nobody looks at each command: it doesn't run
834
+ self.console.print(Text(f" ⎿ {text}", style="red"))
835
+ return f"Error: lcode's secret check stopped this command. {text}\n\n{secretscan.ADVICE}"
836
+ body = Group(Text(text + "\n", style="red"), Syntax(command, "bash", theme="monokai", word_wrap=True))
837
+ key = "secret:" + hashlib.sha256(text.encode()).hexdigest()[:16]
838
+ ok, feedback = self.agent.perms.request(
839
+ key, "secret", f"This {action} looks like it holds a secret", body, command
840
+ )
841
+ if not ok:
842
+ return f"{feedback}\n\n{text}\n{secretscan.ADVICE}"
843
+ approved = True
844
+ if (
845
+ not approved
846
+ and not is_read_only(command)
847
+ and not (sandbox and self.agent.perms.mode == "auto-edit")
848
+ and verdict is None
849
+ ):
827
850
  body = Syntax(command, "bash", theme="monokai", word_wrap=True)
828
851
  where = "in the sandbox" if sandbox else f"in {self.agent.cwd}"
829
852
  ok, feedback = self.agent.perms.request(bash_key(command), "bash", f"Run command ({where})", body, command)
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
+ import os
5
+ import subprocess
4
6
  from pathlib import Path
5
7
 
6
8
  import pytest
@@ -106,3 +108,41 @@ def agent(make_agent) -> Agent:
106
108
 
107
109
  def output(agent: Agent) -> str:
108
110
  return agent.console.file.getvalue()
111
+
112
+
113
+ def run(cwd, *args) -> str:
114
+ return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout
115
+
116
+
117
+ @pytest.fixture
118
+ def git_repo(repo, tmp_path_factory, monkeypatch):
119
+ """The demo repository under git, with one commit on main and git isolated from the user's settings."""
120
+ home = tmp_path_factory.mktemp("home")
121
+ (home / "gitconfig").write_text("[user]\n\tname = Test\n\temail = test@example.com\n")
122
+ monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(home / "gitconfig"))
123
+ monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
124
+ (repo / ".gitignore").write_text("node_modules/\n")
125
+ run(repo, "init", "-q", "-b", "main")
126
+ run(repo, "add", "-A")
127
+ run(repo, "commit", "-q", "-m", "fix: start the demo")
128
+ return repo
129
+
130
+
131
+ @pytest.fixture
132
+ def github(git_repo, tmp_path_factory, monkeypatch):
133
+ """A bare repository as origin, and a stand-in gh that logs its arguments."""
134
+ remote = tmp_path_factory.mktemp("remote") / "demo.git"
135
+ run(remote.parent, "init", "-q", "--bare", "-b", "main", str(remote))
136
+ run(git_repo, "remote", "add", "origin", str(remote))
137
+ run(git_repo, "push", "-q", "-u", "origin", "main")
138
+ bin_dir = tmp_path_factory.mktemp("bin")
139
+ log = bin_dir / "gh.log"
140
+ gh = bin_dir / "gh"
141
+ gh.write_text(
142
+ f'#!/bin/sh\nprintf "%s\\n" "$*" >> {log}\n'
143
+ 'if [ "$1 $2" = "pr view" ]; then exit 1; fi\n'
144
+ 'if [ "$1 $2" = "pr create" ]; then echo https://github.com/acme/demo/pull/7; fi\n'
145
+ )
146
+ gh.chmod(0o755)
147
+ monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}")
148
+ return remote, log