-
Notifications
You must be signed in to change notification settings - Fork 421
Feature/db write retry 保存小说(带写锁重试机制) #123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e1cb8a1
d7281e3
b56775f
23c6a9a
a9ce64a
d36c66f
f204544
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -123,7 +123,7 @@ llm_profiles.json | |
| aitext.lock | ||
| scripts/aitext.lock | ||
|
|
||
| # ── Tauri 桌面壳(仅本机打包用,勿提交;协作者 clone 后无此目录,不影响 npm run dev) ── | ||
| # ── Tauri 桌面壳(仅本机打包用,勿提交) ── | ||
| frontend/src-tauri/ | ||
|
|
||
| # ── 仅本机 Windows 打包链路(协作者只需 npm run dev + 后端) ── | ||
|
|
@@ -150,3 +150,12 @@ mcps/ | |
| *.rs.bk | ||
| *.orig | ||
| *.rej | ||
|
|
||
| # ════════════════════════════════════════════ | ||
| # 本地临时文件和目录(自定义,不入库) | ||
| # ════════════════════════════════════════════ | ||
| %USERPROFILE%/ | ||
| .history/ | ||
| frontend/%USERPROFILE%/ | ||
| test_llm.py | ||
| .gitignore | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CRITICAL: Remove Adding 🚨 Proposed fix frontend/%USERPROFILE%/
test_llm.py
-.gitignore🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,14 @@ export default defineConfig({ | |
| timeout: 0, | ||
| // 不要重写路径 | ||
| rewrite: (path) => path, | ||
| // 🔥 关键:跟随重定向(让代理处理,而非浏览器) | ||
| followRedirects: true, | ||
| // 🔥 添加:错误处理 | ||
| onProxyRes: (proxyRes, req, res) => { | ||
| if (proxyRes.statusCode === 307 || proxyRes.statusCode === 308) { | ||
| console.log('⚠️ Redirect detected:', proxyRes.headers.location); | ||
| } | ||
| } | ||
|
Comment on lines
+30
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In Vite's dev server proxy (powered by http-proxy), the followRedirects option controls whether the proxy automatically follows HTTP redirects (3xx responses) from the target server. Default is false, meaning redirects are passed through to the client as-is. To enable automatic following of redirects, pass followRedirects: true in the proxy options via vite.config.js: export default defineConfig({ server: { proxy: { '/api': { target: 'http://example.com', followRedirects: true, // other options... } } } }) Regarding onProxyRes behavior: This event fires for every response from the target, including redirects (e.g., 301/302). If followRedirects: true, the proxy internally follows the redirect (using the follow-redirects dependency), so onProxyRes receives the final response after all redirects. If false (default), onProxyRes receives the initial redirect response, which you can inspect/modify (e.g., rewrite Location header) or handle manually. To customize onProxyRes, use the configure function to access the proxy instance: '/api': { target: 'http://example.com', configure: (proxy) => { proxy.on('proxyRes', (proxyRes, req, res) => { if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400) { // Handle redirect: e.g., rewrite Location const location = proxyRes.headers.location; proxyRes.headers.location = location.replace('old', 'new'); } }); } } Note: Setting selfHandleResponse: true requires manually ending the response in onProxyRes handlers. Redirects are not followed automatically unless followRedirects: true. Use options like hostRewrite, autoRewrite, or protocolRewrite for automatic Location header rewriting on redirects without following them. Citations:
Remove the redirect logging in When Either remove the logging or set 🤖 Prompt for AI Agents |
||
| }, | ||
| }, | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,11 +44,6 @@ def __init__(self, settings: Settings): | |
| if settings.base_url: | ||
| client_kwargs["base_url"] = settings.base_url | ||
|
|
||
| self._http_client = httpx.AsyncClient( | ||
| timeout=httpx.Timeout(settings.timeout_seconds), | ||
| trust_env=False, | ||
| ) | ||
| client_kwargs["http_client"] = self._http_client | ||
| self.async_client = AsyncOpenAI(**client_kwargs) | ||
|
|
||
| async def generate( | ||
|
|
@@ -85,8 +80,16 @@ async def _generate_via_chat(self, prompt: Prompt, config: GenerationConfig) -> | |
| """Chat Completions API 非流式生成""" | ||
| messages = self._build_messages(prompt) | ||
| request_kwargs = self._build_chat_request_kwargs(messages, config) | ||
| print(f"[LLM] 准备调用 Chat API,模型: {request_kwargs.get('model')},max_tokens: {request_kwargs.get('max_tokens')}") | ||
| try: | ||
| response = await self.async_client.chat.completions.create(**request_kwargs) | ||
| print(f"[LLM] 收到响应,status: success") | ||
| except Exception as e: | ||
| print(f"[LLM] 请求异常: {type(e).__name__}: {e}") | ||
| import traceback | ||
| traceback.print_exc() | ||
| raise | ||
|
Comment on lines
+83
to
+91
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace Using Also, line 86 has an f-string without placeholders (flagged by static analysis). 🛠️ Proposed fix- print(f"[LLM] 准备调用 Chat API,模型: {request_kwargs.get('model')},max_tokens: {request_kwargs.get('max_tokens')}")
+ logger.debug("Calling Chat API, model: %s, max_tokens: %s", request_kwargs.get('model'), request_kwargs.get('max_tokens'))
try:
response = await self.async_client.chat.completions.create(**request_kwargs)
- print(f"[LLM] 收到响应,status: success")
+ logger.debug("Chat API response received successfully")
except Exception as e:
- print(f"[LLM] 请求异常: {type(e).__name__}: {e}")
- import traceback
- traceback.print_exc()
+ logger.error("Chat API request failed: %s: %s", type(e).__name__, e, exc_info=True)
raise🧰 Tools🪛 Ruff (0.15.10)[warning] 83-83: String contains ambiguous (RUF001) [warning] 83-83: String contains ambiguous (RUF001) [error] 86-86: f-string without any placeholders Remove extraneous (F541) [warning] 86-86: String contains ambiguous (RUF001) 🤖 Prompt for AI Agents |
||
|
|
||
| response = await self.async_client.chat.completions.create(**request_kwargs) | ||
| content = self._extract_text_from_response(response) | ||
|
|
||
| if not content: | ||
|
|
@@ -99,6 +102,7 @@ async def _generate_via_chat(self, prompt: Prompt, config: GenerationConfig) -> | |
|
|
||
| input_tokens = response.usage.prompt_tokens if response.usage else 0 | ||
| output_tokens = response.usage.completion_tokens if response.usage else 0 | ||
| print(f"[LLM] 生成成功,内容长度: {len(content)},输入tokens: {input_tokens},输出tokens: {output_tokens}") | ||
| return GenerationResult( | ||
| content=content, | ||
| token_usage=TokenUsage(input_tokens=input_tokens, output_tokens=output_tokens), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,7 +18,7 @@ def __init__(self, db: DatabaseConnection): | |
| self.db = db | ||
|
|
||
| def save(self, novel: Novel) -> None: | ||
| """保存小说""" | ||
| """保存小说(带写锁重试机制)""" | ||
| sql = """ | ||
| INSERT INTO novels ( | ||
| id, title, slug, author, target_chapters, premise, | ||
|
|
@@ -84,7 +84,6 @@ def save(self, novel: Novel) -> None: | |
| ladr = 1 if getattr(novel, "last_audit_drift_alert", False) else 0 | ||
| lano = 1 if getattr(novel, "last_audit_narrative_ok", True) else 0 | ||
| laat = getattr(novel, "last_audit_at", None) | ||
| # 新增字段 | ||
| lavs = 1 if getattr(novel, "last_audit_vector_stored", False) else 0 | ||
| lafs = 1 if getattr(novel, "last_audit_foreshadow_stored", False) else 0 | ||
| late = 1 if getattr(novel, "last_audit_triples_extracted", False) else 0 | ||
|
|
@@ -94,38 +93,35 @@ def save(self, novel: Novel) -> None: | |
| lai_json = json.dumps(lai) if lai else None | ||
| twpc = getattr(novel, "target_words_per_chapter", 2500) | ||
|
|
||
| self.db.execute(sql, ( | ||
| novel_id, | ||
| novel.title, | ||
| slug, | ||
| author, | ||
| novel.target_chapters, | ||
| premise, | ||
| autopilot_status, | ||
| auto_approve_mode, | ||
| current_stage, | ||
| current_act, | ||
| current_chapter_in_act, | ||
| max_auto_chapters, | ||
| current_auto_chapters, | ||
| last_chapter_tension, | ||
| consecutive_error_count, | ||
| current_beat_index, | ||
| lacn, | ||
| lasim, | ||
| ladr, | ||
| lano, | ||
| laat, | ||
| lavs, | ||
| lafs, | ||
| late, | ||
| laqs_json, | ||
| lai_json, | ||
| twpc, | ||
| now, | ||
| now | ||
| )) | ||
| self.db.get_connection().commit() | ||
| params = ( | ||
| novel_id, novel.title, slug, author, novel.target_chapters, premise, | ||
| autopilot_status, auto_approve_mode, current_stage, current_act, current_chapter_in_act, | ||
| max_auto_chapters, current_auto_chapters, last_chapter_tension, | ||
| consecutive_error_count, current_beat_index, | ||
| lacn, lasim, ladr, lano, laat, | ||
| lavs, lafs, late, laqs_json, lai_json, twpc, | ||
| now, now | ||
| ) | ||
|
|
||
| import time | ||
| import sqlite3 | ||
| import random | ||
|
|
||
| max_retries = 15 | ||
| base_delay = 1.0 | ||
|
|
||
| for attempt in range(max_retries): | ||
| try: | ||
| self.db.execute(sql, params) | ||
| self.db.get_connection().commit() | ||
| return | ||
| except sqlite3.OperationalError as e: | ||
| if "database is locked" in str(e) and attempt < max_retries - 1: | ||
| wait = base_delay * (2 ** attempt) + random.uniform(0, 1.0) | ||
| logger.warning(f"数据库写锁冲突,{wait:.2f}秒后重试 (attempt {attempt + 1}/{max_retries})") | ||
| time.sleep(wait) | ||
| continue | ||
| raise | ||
|
Comment on lines
+106
to
+124
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exponential backoff grows unbounded and inconsistent with connection timeout. The retry logic uses
This is inconsistent with the 30-second Consider capping the maximum delay or reducing 🛠️ Proposed fix with capped delay max_retries = 15
base_delay = 1.0
+ max_delay = 30.0 # Cap to match busy_timeout
for attempt in range(max_retries):
try:
self.db.execute(sql, params)
self.db.get_connection().commit()
return
except sqlite3.OperationalError as e:
if "database is locked" in str(e) and attempt < max_retries - 1:
- wait = base_delay * (2 ** attempt) + random.uniform(0, 1.0)
+ wait = min(base_delay * (2 ** attempt), max_delay) + random.uniform(0, 1.0)
logger.warning(f"数据库写锁冲突,{wait:.2f}秒后重试 (attempt {attempt + 1}/{max_retries})")
time.sleep(wait)
continue
raise🧰 Tools🪛 Ruff (0.15.10)[error] 120-120: Standard pseudo-random generators are not suitable for cryptographic purposes (S311) [warning] 121-121: String contains ambiguous (RUF001) 🤖 Prompt for AI Agents |
||
|
|
||
| async def async_save(self, novel: Novel) -> None: | ||
| """异步保存小说(守护进程使用)""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,18 +21,35 @@ | |
| from application.paths import get_db_path | ||
| from interfaces.api.dependencies import get_database | ||
|
|
||
| import os | ||
| from infrastructure.ai.providers.openai_provider import OpenAIProvider | ||
| from infrastructure.ai.config.settings import Settings | ||
|
|
||
|
|
||
| from infrastructure.persistence.database.connection import get_database | ||
|
|
||
|
|
||
| router = APIRouter(prefix="/api/v1/planning", tags=["continuous-planning"]) | ||
|
|
||
|
|
||
| # ==================== DTOs ==================== | ||
|
|
||
|
|
||
| from pydantic import BaseModel, Field | ||
|
|
||
|
|
||
|
|
||
|
|
||
| class StructurePreference(BaseModel): | ||
| """结构偏好""" | ||
| parts: int = Field(3, ge=1, le=10) | ||
| volumes_per_part: int = Field(3, ge=1, le=10) | ||
| acts_per_volume: int = Field(3, ge=1, le=10) | ||
|
|
||
| # class GenerateMacroPlanRequest(BaseModel): | ||
| # target_chapters: int | ||
| # structure: StructurePreference = Field(default_factory=StructurePreference) | ||
|
|
||
|
|
||
| class MacroPlanRequest(BaseModel): | ||
| """宏观规划请求""" | ||
|
|
@@ -62,28 +79,93 @@ class ContinuePlanningRequest(BaseModel): | |
|
|
||
| # ==================== 依赖注入 ==================== | ||
|
|
||
| # def get_service() -> ContinuousPlanningService: | ||
| # """获取规划服务""" | ||
| # db_path = get_db_path() | ||
| # story_node_repo = StoryNodeRepository(db_path) | ||
| # chapter_element_repo = ChapterElementRepository(db_path) | ||
|
|
||
| # # 获取 LLM 服务 | ||
| # import os | ||
| # from infrastructure.ai.providers.anthropic_provider import AnthropicProvider | ||
| # from infrastructure.ai.config.settings import Settings | ||
|
|
||
| # llm_service = None | ||
| # api_key = os.getenv("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_AUTH_TOKEN") | ||
| # if api_key: | ||
| # settings = Settings( | ||
| # api_key=api_key.strip(), | ||
| # base_url=os.getenv("ANTHROPIC_BASE_URL") | ||
| # ) | ||
| # try: | ||
| # llm_service = AnthropicProvider(settings) | ||
| # except Exception: | ||
| # pass | ||
|
|
||
| # from application.world.services.bible_service import BibleService | ||
| # from interfaces.api.dependencies import get_bible_repository | ||
|
|
||
| # bible_service = BibleService(get_bible_repository()) | ||
|
|
||
| # return ContinuousPlanningService( | ||
| # story_node_repo, | ||
| # chapter_element_repo, | ||
| # llm_service, | ||
| # bible_service, | ||
| # chapter_repository=SqliteChapterRepository(get_database()), | ||
| # ) | ||
|
|
||
| def get_service() -> ContinuousPlanningService: | ||
| """获取规划服务""" | ||
| """获取规划服务(优先使用 OpenAI 兼容配置)""" | ||
| db_path = get_db_path() | ||
| story_node_repo = StoryNodeRepository(db_path) | ||
| chapter_element_repo = ChapterElementRepository(db_path) | ||
|
|
||
| # 获取 LLM 服务 | ||
| import os | ||
| from infrastructure.ai.providers.anthropic_provider import AnthropicProvider | ||
| from infrastructure.ai.config.settings import Settings | ||
|
|
||
| llm_service = None | ||
| api_key = os.getenv("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_AUTH_TOKEN") | ||
| if api_key: | ||
| settings = Settings( | ||
| api_key=api_key.strip(), | ||
| base_url=os.getenv("ANTHROPIC_BASE_URL") | ||
| ) | ||
|
|
||
| # ---------- 1. 优先尝试 OpenAI 兼容配置(NVIDIA NIM 代理) ---------- | ||
| openai_api_key = os.getenv('OPENAI_API_KEY') | ||
| openai_base_url = os.getenv('OPENAI_BASE_URL') | ||
|
|
||
| if openai_api_key and openai_base_url: | ||
| try: | ||
| llm_service = AnthropicProvider(settings) | ||
| except Exception: | ||
| pass | ||
| from infrastructure.ai.providers.openai_provider import OpenAIProvider | ||
| settings = Settings() | ||
| settings.api_key = openai_api_key.strip() | ||
| settings.base_url = openai_base_url.strip() | ||
| settings.default_model = os.getenv('DEFAULT_LLM_MODEL_NAME', 'qwen/qwen3.5-397b-a17b') | ||
| settings.use_legacy_chat_completions = True # 关键:使用 /v1/chat/completions | ||
| settings.timeout_seconds = 300.0 | ||
| # ✅ 直接使用 OpenAIProvider,不需要包装成 LLMService | ||
| llm_service = OpenAIProvider(settings=settings) | ||
| print(f"✅ 使用 OpenAIProvider 初始化 LLM 服务: {settings.default_model} @ {settings.base_url}") | ||
| except Exception as e: | ||
| print(f"⚠️ OpenAIProvider 初始化失败: {e}") | ||
|
|
||
| # ---------- 2. 降级:尝试 Anthropic ---------- | ||
| if llm_service is None: | ||
| api_key = os.getenv("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_AUTH_TOKEN") | ||
| if api_key: | ||
| try: | ||
| from infrastructure.ai.providers.anthropic_provider import AnthropicProvider | ||
| settings = Settings( | ||
| api_key=api_key.strip(), | ||
| base_url=os.getenv("ANTHROPIC_BASE_URL") | ||
| ) | ||
| llm_service = AnthropicProvider(settings=settings) | ||
| print("✅ 使用 AnthropicProvider 初始化 LLM 服务") | ||
| except Exception: | ||
| pass | ||
|
Comment on lines
+159
to
+160
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't silently swallow exceptions in Anthropic fallback. The bare 🛠️ Proposed fix except Exception:
- pass
+ logger.warning("AnthropicProvider initialization failed", exc_info=True)Note: You'll need to add 🧰 Tools🪛 Ruff (0.15.10)[error] 159-160: (S110) [warning] 159-159: Do not catch blind exception: (BLE001) 🤖 Prompt for AI Agents |
||
|
|
||
| # ---------- 3. 检查是否成功 ---------- | ||
| if llm_service is None: | ||
| raise RuntimeError( | ||
| "无法初始化任何 LLM 服务。请检查环境变量:\n" | ||
| " - OpenAI: OPENAI_API_KEY, OPENAI_BASE_URL\n" | ||
| " - Anthropic: ANTHROPIC_API_KEY 或 ANTHROPIC_AUTH_TOKEN" | ||
| ) | ||
|
|
||
| from application.world.services.bible_service import BibleService | ||
| from interfaces.api.dependencies import get_bible_repository | ||
|
|
@@ -93,25 +175,69 @@ def get_service() -> ContinuousPlanningService: | |
| return ContinuousPlanningService( | ||
| story_node_repo, | ||
| chapter_element_repo, | ||
| llm_service, | ||
| llm_service, # 这里直接传入 OpenAIProvider 实例 | ||
| bible_service, | ||
| chapter_repository=SqliteChapterRepository(get_database()), | ||
| ) | ||
|
|
||
|
|
||
| # ==================== 宏观规划 API ==================== | ||
|
|
||
| @router.post("/novels/{novel_id}/macro/generate", status_code=202) | ||
| # async def generate_macro_plan( | ||
| # novel_id: str, | ||
| # request: MacroPlanRequest, | ||
| # background_tasks: BackgroundTasks, | ||
| # service: ContinuousPlanningService = Depends(get_service) | ||
| # ): | ||
| # #生成宏观规划 | ||
|
|
||
| # #生成部-卷-幕结构框架,不保存,返回供用户编辑 | ||
|
|
||
| # try: | ||
| # print(f"[DEBUG] 路由层: 收到请求 novel_id={novel_id}, request={request}") | ||
| # service.initialize_macro_plan_task(novel_id) | ||
|
|
||
| # async def _generate_task(): | ||
| # try: | ||
| # result = await service.generate_macro_plan( | ||
| # novel_id=novel_id, | ||
| # target_chapters=request.target_chapters, | ||
| # structure_preference=request.structure.dict() | ||
| # ) | ||
| # service.store_macro_plan_result(novel_id, result) | ||
| # except Exception as e: | ||
| # import traceback | ||
| # print(f"[ERROR] 生成宏观规划失败:") | ||
| # print(traceback.format_exc()) | ||
| # service.store_macro_plan_error(novel_id, str(e)) | ||
| # service._update_macro_progress( | ||
| # novel_id, | ||
| # status="failed", | ||
| # message=f"结构规划生成失败: {e}", | ||
| # ) | ||
|
|
||
| # background_tasks.add_task(_generate_task) | ||
| # return { | ||
| # "success": True, | ||
| # "task_started": True, | ||
| # "novel_id": novel_id, | ||
| # } | ||
| # except Exception as e: | ||
| # import traceback | ||
| # print(f"[ERROR] 生成宏观规划失败:") | ||
| # print(traceback.format_exc()) | ||
| # raise HTTPException(status_code=500, detail=f"生成宏观规划失败: {str(e)}") | ||
|
|
||
| async def generate_macro_plan( | ||
| novel_id: str, | ||
| request: MacroPlanRequest, | ||
| background_tasks: BackgroundTasks, | ||
| service: ContinuousPlanningService = Depends(get_service) | ||
| ): | ||
| """生成宏观规划 | ||
| #生成宏观规划 | ||
|
|
||
| 生成部-卷-幕结构框架,不保存,返回供用户编辑 | ||
| """ | ||
| #生成部-卷-幕结构框架,不保存,返回供用户编辑 | ||
|
|
||
| try: | ||
| print(f"[DEBUG] 路由层: 收到请求 novel_id={novel_id}, request={request}") | ||
| service.initialize_macro_plan_task(novel_id) | ||
|
|
@@ -148,6 +274,8 @@ async def _generate_task(): | |
| raise HTTPException(status_code=500, detail=f"生成宏观规划失败: {str(e)}") | ||
|
|
||
|
|
||
|
|
||
|
|
||
| @router.get("/novels/{novel_id}/macro/progress") | ||
| async def get_macro_plan_generation_progress(novel_id: str): | ||
| """获取精密结构规划的实时进度。""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: shenminglinyi/PlotPilot
Length of output: 49
🏁 Script executed:
Repository: shenminglinyi/PlotPilot
Length of output: 275
🌐 Web query:
Does git expand environment variables in .gitignore patterns like %USERPROFILE%💡 Result:
No, Git does not expand environment variables like %USERPROFILE% in .gitignore patterns. .gitignore patterns are treated as literal static strings using fnmatch-style glob matching without shell expansion or variable substitution. Official Git documentation for gitignore(5) describes the PATTERN FORMAT exhaustively, listing supported syntax like wildcards (*, ?), character ranges, for recursive directories, negation (!), escapes (), and comments (#), but makes no mention of environment variable expansion. Patterns follow fnmatch(3) rules with Git-specific extensions for paths, confirming literal interpretation. Stack Overflow discussions confirm this: users attempting constructs like .$(echo %username%) or conditional env-based ignores are told patterns are "completely static" with no shell/language expansion. For user-specific ignores (e.g., Windows username files), solutions involve per-repo .git/info/exclude or local core.excludesfile with hardcoded paths, not variables in patterns. Even core.excludesfile paths support ~ expansion in git-config (e.g., ~/foo expands to $HOME/foo), but patterns within these files remain literal. No evidence of %VAR% or $VAR expansion inside patterns across Git versions up to 2.53 (2025). Workarounds for dynamic ignores: - Generate .gitignore via scripts/Makefile before Git operations. - Use repo-local .git/info/exclude for uncommitted user-specific rules. - Global core.excludesfile = %USERPROFILE%.gitignore (expands in config value, not pattern). To test: echo "%USERPROFILE%/temp" > .gitignore; touch "$HOME/temp.txt"; git status --ignored --others (temp.txt appears unless path literally matches %USERPROFILE%).
Citations:
Environment variable patterns won't work in
.gitignore.Git does not expand environment variables in
.gitignorepatterns; they are treated as literal static strings. These patterns will only match directories literally named%USERPROFILE%/, not the actual user profile path.Based on the comment here, you intend to ignore local temporary directories. Use concrete path patterns instead, or consider using
.git/info/excludefor local-only ignore rules.🤖 Prompt for AI Agents