mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-03 08:15:53 +00:00
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:
parent
8f8e41fe06
commit
fb28678b64
1
.gitignore
vendored
1
.gitignore
vendored
@ -92,3 +92,4 @@ logs/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
.oss/
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user