fix: prevent GitStore from creating nested repos and overwriting .gitignore (#2980)

GitStore.init() now checks if the workspace is already inside a git
repository before calling porcelain.init(). If so, it refuses to create
a nested repo. Additionally, existing .gitignore files are preserved
by appending only missing Dream-specific entries rather than overwriting.

Closes #2980
This commit is contained in:
longle325 2026-04-18 23:16:04 +07:00 committed by Xubin Ren
parent 8f8e41fe06
commit fb28678b64
3 changed files with 121 additions and 3 deletions

1
.gitignore vendored
View File

@ -92,3 +92,4 @@ logs/
tmp/ tmp/
temp/ temp/
*.tmp *.tmp
.oss/

View File

@ -64,14 +64,35 @@ class GitStore:
if self.is_initialized(): if self.is_initialized():
return False return False
if self._is_inside_git_repo():
logger.warning(
"Workspace {} is already inside a git repo; "
"skipping nested repo initialization",
self._workspace,
)
return False
try: try:
from dulwich import porcelain from dulwich import porcelain
porcelain.init(str(self._workspace)) porcelain.init(str(self._workspace))
# Write .gitignore # Write .gitignore (merge with existing if present)
gitignore = self._workspace / ".gitignore" gitignore = self._workspace / ".gitignore"
gitignore.write_text(self._build_gitignore(), encoding="utf-8") dream_entries = self._build_gitignore()
if gitignore.exists():
existing = gitignore.read_text(encoding="utf-8")
existing_lines = set(existing.splitlines())
new_lines = [
line
for line in dream_entries.splitlines()
if line not in existing_lines
]
if new_lines:
merged = existing.rstrip("\n") + "\n" + "\n".join(new_lines) + "\n"
gitignore.write_text(merged, encoding="utf-8")
else:
gitignore.write_text(dream_entries, encoding="utf-8")
# Ensure tracked files exist (touch them if missing) so the initial # Ensure tracked files exist (touch them if missing) so the initial
# commit has something to track. # commit has something to track.
@ -155,6 +176,19 @@ class GitStore:
except Exception: except Exception:
return None return None
def _is_inside_git_repo(self) -> bool:
"""Check if self._workspace is already inside a git repository.
Walks up from self._workspace to the filesystem root, returning True
if any parent directory contains a .git directory.
"""
current = self._workspace.resolve()
while current != current.parent:
if (current / ".git").is_dir():
return True
current = current.parent
return False
def _build_gitignore(self) -> str: def _build_gitignore(self) -> str:
"""Generate .gitignore content from tracked files.""" """Generate .gitignore content from tracked files."""
dirs: set[str] = set() dirs: set[str] = set()

View File

@ -1,7 +1,7 @@
"""Tests for GitStore — line_ages() and core git operations.""" """Tests for GitStore — line_ages() and core git operations."""
import time import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timedelta, timezone
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -89,3 +89,86 @@ class TestLineAges:
# "- new" line and "- keep" line both age=0 (same day), but # "- new" line and "- keep" line both age=0 (same day), but
# the key point is we get per-line results # the key point is we get per-line results
assert len(ages) == 7 assert len(ages) == 7
class TestNestedRepoProtection:
"""Regression tests for GitHub issue #2980: nested repo protection."""
def test_init_refuses_inside_git_repo(self, tmp_path):
"""init() should detect it's inside an existing git repo and refuse."""
project = tmp_path / "project"
project.mkdir()
(project / ".git").mkdir()
workspace = project / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is False
assert not (workspace / ".git").is_dir()
def test_init_preserves_existing_gitignore(self, tmp_path):
"""init() should preserve existing .gitignore entries and append new ones."""
workspace = tmp_path / "workspace"
workspace.mkdir()
existing = "*.pyc\n__pycache__/\n"
(workspace / ".gitignore").write_text(existing, encoding="utf-8")
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
gitignore = (workspace / ".gitignore").read_text(encoding="utf-8")
assert "*.pyc" in gitignore
assert "__pycache__/" in gitignore
assert "!MEMORY.md" in gitignore
assert "!.gitignore" in gitignore
def test_init_no_gitignore_creates_new(self, tmp_path):
"""init() should create .gitignore with Dream content when none exists."""
workspace = tmp_path / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
gitignore = (workspace / ".gitignore").read_text(encoding="utf-8")
expected = g._build_gitignore()
assert gitignore == expected
def test_init_gitignore_merge_idempotent(self, tmp_path):
"""init() should not duplicate Dream entries already in .gitignore."""
workspace = tmp_path / "workspace"
workspace.mkdir()
# Pre-existing .gitignore that already has some Dream entries
existing = "*.pyc\n/*\n!MEMORY.md\n"
(workspace / ".gitignore").write_text(existing, encoding="utf-8")
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
gitignore = (workspace / ".gitignore").read_text(encoding="utf-8")
# No duplicate lines
lines = gitignore.splitlines()
assert lines.count("/*") == 1
assert lines.count("!MEMORY.md") == 1
# Existing entry preserved, new Dream entries appended
assert "*.pyc" in gitignore
assert "!.gitignore" in gitignore
def test_init_outside_git_repo_works_normally(self, tmp_path):
"""init() should succeed and create .git when not inside a git repo."""
workspace = tmp_path / "workspace"
workspace.mkdir()
g = GitStore(workspace, tracked_files=["MEMORY.md"])
result = g.init()
assert result is True
assert (workspace / ".git").is_dir()