diff --git a/backend/app/api/v1/git_repos.py b/backend/app/api/v1/git_repos.py
index 2c11299..619fec8 100644
--- a/backend/app/api/v1/git_repos.py
+++ b/backend/app/api/v1/git_repos.py
@@ -18,6 +18,13 @@ from app.core.enums import OperationType
router = APIRouter()
+def _repo_response(repo: ProjectGitRepo) -> dict:
+ data = GitRepoResponse.from_orm(repo).dict()
+ data["token"] = None
+ data["has_token"] = bool(repo.token)
+ return data
+
+
@router.get("/projects/{project_id}/git-repos", response_model=dict)
async def get_project_git_repos(
project_id: int,
@@ -35,9 +42,7 @@ async def get_project_git_repos(
# 隐藏 token
data = []
for repo in repos:
- repo_dict = GitRepoResponse.from_orm(repo).dict()
- # repo_dict['token'] = '******' if repo.token else None # 前端可能需要回显或者判断是否有token
- data.append(repo_dict)
+ data.append(_repo_response(repo))
return success_response(data=data)
@@ -80,13 +85,14 @@ async def create_git_repo(
branch=repo_in.branch,
username=repo_in.username,
token=repo_in.token,
- is_default=is_default
+ is_default=is_default,
+ sync_path=repo_in.sync_path or ""
)
db.add(db_repo)
await db.commit()
await db.refresh(db_repo)
- return success_response(data=GitRepoResponse.from_orm(db_repo).dict(), message="Git仓库添加成功")
+ return success_response(data=_repo_response(db_repo), message="Git仓库添加成功")
@router.put("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)
@@ -121,13 +127,16 @@ async def update_git_repo(
)
update_data = repo_in.dict(exclude_unset=True)
+ # token 不回显;编辑时留空表示继续使用已保存凭据。
+ if not update_data.get("token"):
+ update_data.pop("token", None)
for field, value in update_data.items():
setattr(repo, field, value)
await db.commit()
await db.refresh(repo)
- return success_response(data=GitRepoResponse.from_orm(repo).dict(), message="更新成功")
+ return success_response(data=_repo_response(repo), message="更新成功")
@router.delete("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)
diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py
index c48f453..d2156a4 100644
--- a/backend/app/api/v1/projects.py
+++ b/backend/app/api/v1/projects.py
@@ -1,6 +1,8 @@
"""
项目管理相关 API
"""
+import asyncio
+
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, or_, func
@@ -100,6 +102,18 @@ async def attach_member_counts(db: AsyncSession, projects) -> dict:
return dict(result.all())
+async def attach_git_repo_counts(db: AsyncSession, projects) -> dict:
+ """批量查询项目已配置的Git仓库数量,返回 {project_id: count}"""
+ if not projects:
+ return {}
+ result = await db.execute(
+ select(ProjectGitRepo.project_id, func.count(ProjectGitRepo.id))
+ .where(ProjectGitRepo.project_id.in_([p.id for p in projects]))
+ .group_by(ProjectGitRepo.project_id)
+ )
+ return dict(result.all())
+
+
@router.get("/", response_model=dict)
async def get_my_projects(
current_user: User = Depends(get_current_user),
@@ -127,6 +141,7 @@ async def get_my_projects(
# 合并结果
all_projects = owned_projects + member_projects
member_counts = await attach_member_counts(db, all_projects)
+ git_repo_counts = await attach_git_repo_counts(db, all_projects)
projects_data = []
for p in all_projects:
p_dict = ProjectResponse.from_orm(p).dict()
@@ -134,6 +149,7 @@ async def get_my_projects(
p_dict['doc_count'] = doc_count
p_dict['last_activity_at'] = last_activity_at
p_dict['member_count'] = member_counts.get(p.id, 0)
+ p_dict['git_repo_count'] = git_repo_counts.get(p.id, 0)
projects_data.append(p_dict)
return success_response(data=projects_data)
@@ -150,6 +166,7 @@ async def get_owned_projects(
)
projects = result.scalars().all()
member_counts = await attach_member_counts(db, projects)
+ git_repo_counts = await attach_git_repo_counts(db, projects)
projects_data = []
for p in projects:
p_dict = ProjectResponse.from_orm(p).dict()
@@ -157,6 +174,7 @@ async def get_owned_projects(
p_dict['doc_count'] = doc_count
p_dict['last_activity_at'] = last_activity_at
p_dict['member_count'] = member_counts.get(p.id, 0)
+ p_dict['git_repo_count'] = git_repo_counts.get(p.id, 0)
projects_data.append(p_dict)
return success_response(data=projects_data)
@@ -181,6 +199,7 @@ async def get_shared_projects(
projects = [project for project, _, _ in projects_with_info]
member_counts = await attach_member_counts(db, projects)
+ git_repo_counts = await attach_git_repo_counts(db, projects)
projects_data = []
for project, owner, member in projects_with_info:
@@ -192,6 +211,7 @@ async def get_shared_projects(
project_dict['doc_count'] = doc_count
project_dict['last_activity_at'] = last_activity_at
project_dict['member_count'] = member_counts.get(project.id, 0)
+ project_dict['git_repo_count'] = git_repo_counts.get(project.id, 0)
projects_data.append(project_dict)
return success_response(data=projects_data)
@@ -710,16 +730,34 @@ async def remove_project_member(
return success_response(message="成员删除成功")
+def _resolve_sync_sub_path(
+ sync_scope: str = None, sub_path: str = None, repo_sync_path: str = ""
+) -> str:
+ """Resolve the configured default scope separately from a one-off operation scope."""
+ if sync_scope is None:
+ return (sub_path if sub_path is not None else repo_sync_path or "").strip().strip("/")
+ if sync_scope == "all":
+ return ""
+ if sync_scope != "path":
+ raise ValueError("同步范围必须是 all 或 path")
+ resolved = (sub_path or "").strip().strip("/")
+ if not resolved:
+ raise ValueError("请选择要同步的目录")
+ return resolved
+
+
@router.post("/{project_id}/git/pull", response_model=dict)
async def git_pull(
project_id: int,
request: Request,
repo_id: int = None,
force: bool = False,
+ sync_scope: str = None,
+ sub_path: str = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
- """执行 Git Pull"""
+ """执行 Git Pull(sub_path 非空时仅同步指定目录)"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
@@ -755,16 +793,25 @@ async def git_pull(
target_repo = repos[0]
+ try:
+ effective_sub_path = _resolve_sync_sub_path(sync_scope, sub_path, target_repo.sync_path)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
project_path = storage_service.get_secure_path(project.storage_key)
- success, msg = await git_service.pull(
- project_path=project_path,
- repo_url=target_repo.repo_url,
- branch=target_repo.branch or "main",
- username=target_repo.username,
- token=target_repo.token,
- force=force
- )
+ try:
+ success, msg = await git_service.pull(
+ project_path=project_path,
+ repo_url=target_repo.repo_url,
+ branch=target_repo.branch or "main",
+ username=target_repo.username,
+ token=target_repo.token,
+ force=force,
+ sub_path=effective_sub_path
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
if not success:
raise HTTPException(status_code=500, detail=f"Git Pull失败: {msg}")
@@ -775,17 +822,18 @@ async def git_pull(
operation_type=OperationType.GIT_PULL,
project_id=project_id,
user=current_user,
- detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force},
+ detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force, "sub_path": effective_sub_path or None},
request=request,
)
# 发送通知给其他成员
+ scope_desc = f",同步目录 [{effective_sub_path}]" if effective_sub_path else ""
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title=f"项目文档已通过 Git 同步",
- content=f"{current_user.nickname or current_user.username} 执行了 Git Pull,项目 [{project.name}] 的内容已从远程仓库同步更新。",
+ content=f"{current_user.nickname or current_user.username} 执行了 Git Pull,项目 [{project.name}] 的内容已从远程仓库同步更新{scope_desc}。",
link=f"/projects/{project_id}/docs",
category="project"
)
@@ -800,10 +848,12 @@ async def git_push(
request: Request,
repo_id: int = None,
force: bool = False,
+ sync_scope: str = None,
+ sub_path: str = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
- """执行 Git Push"""
+ """执行 Git Push(sub_path 非空时仅推送指定目录的变更)"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
@@ -839,16 +889,25 @@ async def git_push(
target_repo = repos[0]
+ try:
+ effective_sub_path = _resolve_sync_sub_path(sync_scope, sub_path, target_repo.sync_path)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
project_path = storage_service.get_secure_path(project.storage_key)
- success, msg = await git_service.push(
- project_path=project_path,
- repo_url=target_repo.repo_url,
- branch=target_repo.branch or "main",
- username=target_repo.username,
- token=target_repo.token,
- force=force
- )
+ try:
+ success, msg = await git_service.push(
+ project_path=project_path,
+ repo_url=target_repo.repo_url,
+ branch=target_repo.branch or "main",
+ username=target_repo.username,
+ token=target_repo.token,
+ force=force,
+ sub_path=effective_sub_path
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
if not success:
raise HTTPException(status_code=500, detail=f"Git Push失败: {msg}")
@@ -859,8 +918,91 @@ async def git_push(
operation_type=OperationType.GIT_PUSH,
project_id=project_id,
user=current_user,
- detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force},
+ detail={"repo": target_repo.repo_url, "branch": target_repo.branch, "repo_alias": target_repo.name, "force": force, "sub_path": effective_sub_path or None},
request=request,
)
return success_response(message=f"Git Push 成功 ({target_repo.name})")
+
+
+def _build_remote_dir_tree(paths):
+ """Build TreeSelect options where each node's value is its full repository path."""
+ root = {"children": {}}
+ for path in paths:
+ parts = path.split("/")[:-1]
+ node = root
+ for part in parts:
+ node = node["children"].setdefault(part, {"children": {}})
+
+ def to_options(node, prefix=""):
+ options = []
+ for name in sorted(node["children"]):
+ child = node["children"][name]
+ value = f"{prefix}/{name}" if prefix else name
+ options.append({
+ "title": name,
+ "value": value,
+ "children": to_options(child, value),
+ })
+ return options
+
+ return to_options(root)
+
+
+@router.post("/{project_id}/git/directories", response_model=dict)
+async def list_git_directories(
+ project_id: int,
+ request: Request,
+ repo_id: int = None,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """列出指定 Git 仓库远端目录结构(用于选择同步目录)"""
+ result = await db.execute(select(Project).where(Project.id == project_id))
+ project = result.scalar_one_or_none()
+ if not project:
+ raise HTTPException(status_code=404, detail="项目不存在")
+
+ # 权限检查:需要是所有者或管理员/编辑者
+ if project.owner_id != current_user.id:
+ member_result = await db.execute(
+ select(ProjectMember).where(
+ ProjectMember.project_id == project_id,
+ ProjectMember.user_id == current_user.id,
+ ProjectMember.role.in_(["admin", "editor"]),
+ )
+ )
+ if not member_result.scalar_one_or_none():
+ raise HTTPException(status_code=403, detail="无权操作Git仓库")
+
+ query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
+ if repo_id:
+ query = query.where(ProjectGitRepo.id == repo_id)
+ else:
+ query = query.order_by(ProjectGitRepo.is_default.desc(), ProjectGitRepo.created_at.desc())
+ result = await db.execute(query)
+ repos = result.scalars().all()
+ if not repos:
+ raise HTTPException(status_code=400, detail="未配置Git仓库")
+
+ target_repo = repos[0]
+ project_path = storage_service.get_secure_path(project.storage_key)
+
+ try:
+ # Git/网络操作是同步阻塞调用,不能直接在 async 事件循环中执行。
+ success, tree_text = await asyncio.to_thread(
+ git_service.list_remote_tree,
+ project_path,
+ target_repo.repo_url,
+ target_repo.branch or "main",
+ target_repo.username,
+ target_repo.token,
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+ if not success:
+ raise HTTPException(status_code=500, detail=f"获取远端目录失败: {tree_text}")
+
+ paths = [line for line in tree_text.splitlines() if line.strip()]
+ return success_response(data={"directories": _build_remote_dir_tree(paths), "repo": target_repo.name})
diff --git a/backend/app/core/migrations.py b/backend/app/core/migrations.py
index 8d808f3..c5cf5b5 100644
--- a/backend/app/core/migrations.py
+++ b/backend/app/core/migrations.py
@@ -34,6 +34,17 @@ CHAT_MESSAGE_COLUMNS = [
),
]
+# 项目 Git 仓库:同步目录(空=整个仓库)。
+# 该列为新增列,需通过幂等 ALTER 补齐,否则存量库查询会报 Unknown column。
+GIT_REPO_COLUMNS = [
+ (
+ "project_git_repos",
+ "sync_path",
+ "ALTER TABLE project_git_repos ADD COLUMN sync_path VARCHAR(255) DEFAULT '' "
+ "COMMENT '同步目录(空=整个仓库)'",
+ ),
+]
+
async def _column_exists(conn, table_name: str, column_name: str) -> bool:
result = await conn.execute(
@@ -57,7 +68,7 @@ async def migrate_schema() -> None:
added = []
async with engine.begin() as conn:
- for table_name, column_name, ddl in CHAT_MESSAGE_COLUMNS:
+ for table_name, column_name, ddl in CHAT_MESSAGE_COLUMNS + GIT_REPO_COLUMNS:
try:
exists = await _column_exists(conn, table_name, column_name)
except Exception as exc: # noqa: BLE001
diff --git a/backend/app/models/git_repo.py b/backend/app/models/git_repo.py
index ebf1ca4..af7326a 100644
--- a/backend/app/models/git_repo.py
+++ b/backend/app/models/git_repo.py
@@ -20,6 +20,7 @@ class ProjectGitRepo(Base):
username = Column(String(100), comment="Git用户名")
token = Column(String(255), comment="Git访问令牌/密码")
is_default = Column(SmallInteger, default=0, comment="是否默认仓库")
+ sync_path = Column(String(255), default="", comment="同步目录(空=整个仓库)")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
diff --git a/backend/app/schemas/git_repo.py b/backend/app/schemas/git_repo.py
index 4b5bdbd..da892c1 100644
--- a/backend/app/schemas/git_repo.py
+++ b/backend/app/schemas/git_repo.py
@@ -13,6 +13,7 @@ class GitRepoBase(BaseModel):
username: Optional[str] = Field(None, description="Git用户名")
token: Optional[str] = Field(None, description="Git访问令牌")
is_default: int = Field(0, description="是否默认仓库")
+ sync_path: str = Field("", max_length=255, description="同步目录(空=整个仓库)")
class GitRepoCreate(GitRepoBase):
@@ -28,6 +29,7 @@ class GitRepoUpdate(BaseModel):
username: Optional[str] = None
token: Optional[str] = None
is_default: Optional[int] = None
+ sync_path: Optional[str] = None
class GitRepoResponse(GitRepoBase):
@@ -36,6 +38,7 @@ class GitRepoResponse(GitRepoBase):
project_id: int
created_at: datetime
updated_at: datetime
+ has_token: bool = False
class Config:
from_attributes = True
diff --git a/backend/app/services/git_service.py b/backend/app/services/git_service.py
index 5fccdb3..ea60e24 100644
--- a/backend/app/services/git_service.py
+++ b/backend/app/services/git_service.py
@@ -1,128 +1,286 @@
-import subprocess
+import base64
+import asyncio
import os
+import shutil
+import subprocess
+import tempfile
from pathlib import Path
-from typing import Optional, Tuple
-from urllib.parse import quote_plus
+from typing import Tuple
+from urllib.parse import urlsplit, urlunsplit
+
class GitService:
-
- def _get_auth_url(self, repo_url: str, username: str = None, token: str = None) -> str:
- """
- Constructs a URL with authentication credentials.
- Note: This is sensitive, so be careful not to log this URL.
- """
- if not username or not token:
- return repo_url
-
- # Encode credentials to handle special characters (e.g. @, :, /)
- safe_username = quote_plus(username)
- safe_token = quote_plus(token)
-
- # Remove scheme if present to insert auth
- if repo_url.startswith("https://"):
- url_body = repo_url[8:]
- return f"https://{safe_username}:{safe_token}@{url_body}"
- elif repo_url.startswith("http://"):
- url_body = repo_url[7:]
- return f"http://{safe_username}:{safe_token}@{url_body}"
-
- return repo_url
+ """Run Git synchronization without persisting repository credentials."""
- def _run_command(self, cmd: list, cwd: Path) -> Tuple[bool, str]:
- """
- Runs a shell command in the given directory.
- Returns (success, message).
- """
+ def _clean_repo_url(self, repo_url: str) -> str:
+ """Remove any user info already embedded in an HTTP(S) URL."""
+ parsed = urlsplit(repo_url)
+ if parsed.scheme not in ("http", "https"):
+ return repo_url
+ host = parsed.hostname or ""
+ if parsed.port:
+ host = f"{host}:{parsed.port}"
+ return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment))
+
+ def _auth_env(self, username: str = None, token: str = None) -> dict:
+ """Pass HTTPS Basic credentials to Git for this process only."""
+ env = {"GIT_TERMINAL_PROMPT": "0"}
+ if username and token:
+ credential = base64.b64encode(f"{username}:{token}".encode()).decode()
+ env.update({
+ "GIT_CONFIG_COUNT": "1",
+ "GIT_CONFIG_KEY_0": "http.extraHeader",
+ "GIT_CONFIG_VALUE_0": f"Authorization: Basic {credential}",
+ })
+ return env
+
+ def _run_command(self, cmd: list, cwd: Path, env: dict = None, timeout: int = 60) -> Tuple[bool, str]:
+ """Run a command and return its output without exposing process environment."""
try:
- # git operations might take time
+ run_env = os.environ.copy()
+ run_env.update({"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"})
+ if env:
+ run_env.update(env)
result = subprocess.run(
cmd,
cwd=str(cwd),
capture_output=True,
text=True,
- check=False # We handle return code manually
+ encoding="utf-8",
+ env=run_env,
+ timeout=timeout,
+ check=False,
)
-
if result.returncode == 0:
return True, result.stdout
+ return False, result.stderr.strip() or result.stdout.strip() or "Git command failed"
+ except subprocess.TimeoutExpired:
+ return False, f"Git 命令超时({timeout} 秒):{' '.join(cmd)}"
+ except Exception as exc: # noqa: BLE001
+ return False, str(exc)
+
+ def _ensure_git_initialized(self, cwd: Path, repo_url: str) -> Tuple[bool, str]:
+ """Ensure the project has a Git repository pointing to the credential-free URL."""
+ if not (cwd / ".git").exists():
+ ok, message = self._run_command(["git", "init"], cwd)
+ if not ok:
+ return False, message
+ ok, message = self._run_command(["git", "branch", "-M", "main"], cwd)
+ if not ok:
+ return False, message
+
+ for key, value in (
+ ("core.quotepath", "false"),
+ ("i18n.logOutputEncoding", "utf-8"),
+ ("core.precomposeunicode", "true"),
+ ):
+ ok, message = self._run_command(["git", "config", key, value], cwd)
+ if not ok:
+ return False, message
+
+ clean_url = self._clean_repo_url(repo_url)
+ ok, _ = self._run_command(["git", "remote", "get-url", "origin"], cwd)
+ if ok:
+ return self._run_command(["git", "remote", "set-url", "origin", clean_url], cwd)
+ return self._run_command(["git", "remote", "add", "origin", clean_url], cwd)
+
+ def _normalize_sub_path(self, sub_path: str = None) -> str:
+ if not sub_path:
+ return ""
+ normalized = str(sub_path).strip().strip("/")
+ if not normalized:
+ return ""
+ parts = normalized.split("/")
+ if any(part in ("", ".", "..") or part.startswith(".") for part in parts):
+ raise ValueError(f"非法的同步目录: {normalized}")
+ return normalized
+
+ def _ensure_commit_identity(self, cwd: Path) -> Tuple[bool, str]:
+ ok, name = self._run_command(["git", "config", "user.name"], cwd)
+ if not ok or not name.strip():
+ ok, message = self._run_command(["git", "config", "user.name", "NEX Docus"], cwd)
+ if not ok:
+ return False, message
+ ok, email = self._run_command(["git", "config", "user.email"], cwd)
+ if not ok or not email.strip():
+ return self._run_command(
+ ["git", "config", "user.email", "noreply@nex-docus.local"], cwd
+ )
+ return True, ""
+
+ def _commit_if_changed(self, cwd: Path, message: str) -> Tuple[bool, str, bool]:
+ ok, status = self._run_command(["git", "status", "--porcelain"], cwd)
+ if not ok:
+ return False, status, False
+ if not status.strip():
+ return True, "", False
+ ok, identity_message = self._ensure_commit_identity(cwd)
+ if not ok:
+ return False, identity_message, False
+ ok, commit_message = self._run_command(["git", "commit", "-m", message], cwd)
+ return ok, commit_message, ok
+
+ def _remote_worktree(self, repo_url: str, branch: str, auth_env: dict):
+ """Create an isolated worktree at the latest remote branch revision."""
+ temp_dir = tempfile.TemporaryDirectory(prefix="nex-docus-git-")
+ root = Path(temp_dir.name)
+ ok, message = self._run_command(["git", "init"], root)
+ if not ok:
+ temp_dir.cleanup()
+ return None, None, message
+ ok, message = self._run_command(
+ ["git", "remote", "add", "origin", self._clean_repo_url(repo_url)], root
+ )
+ if not ok:
+ temp_dir.cleanup()
+ return None, None, message
+ ok, message = self._run_command(["git", "fetch", "origin"], root, auth_env)
+ if not ok:
+ temp_dir.cleanup()
+ return None, None, message
+
+ remote_ref = f"origin/{branch}"
+ ok, _ = self._run_command(["git", "rev-parse", "--verify", remote_ref], root)
+ if ok:
+ ok, message = self._run_command(["git", "checkout", "-B", branch, remote_ref], root)
+ else:
+ ok, message = self._run_command(["git", "checkout", "-B", branch], root)
+ if not ok:
+ temp_dir.cleanup()
+ return None, None, message
+ return temp_dir, root, ""
+
+ def _replace_path(self, source_root: Path, target_root: Path, sub_path: str) -> None:
+ source = source_root / sub_path
+ target = target_root / sub_path
+ if target.exists() or target.is_symlink():
+ if target.is_dir() and not target.is_symlink():
+ shutil.rmtree(target)
else:
- return False, f"Command failed: {' '.join(cmd)}\nError: {result.stderr}"
- except Exception as e:
- return False, str(e)
+ target.unlink()
+ if source.exists():
+ target.parent.mkdir(parents=True, exist_ok=True)
+ if source.is_dir():
+ shutil.copytree(source, target)
+ else:
+ shutil.copy2(source, target)
- def _ensure_git_initialized(self, cwd: Path, auth_url: str):
- """
- Ensures the directory is a git repository and has the correct remote.
- """
- git_dir = cwd / ".git"
- if not git_dir.exists():
- self._run_command(["git", "init"], cwd)
- self._run_command(["git", "branch", "-M", "main"], cwd) # Default to main
-
- # Check remote
- success, output = self._run_command(["git", "remote", "get-url", "origin"], cwd)
- if not success:
- # Remote doesn't exist, add it
- self._run_command(["git", "remote", "add", "origin", auth_url], cwd)
- else:
- # Remote exists, update it (in case credentials or URL changed)
- current_url = output.strip()
- if current_url != auth_url:
- self._run_command(["git", "remote", "set-url", "origin", auth_url], cwd)
-
- async def pull(self, project_path: Path, repo_url: str, branch: str = "main", username: str = None, token: str = None, force: bool = False) -> Tuple[bool, str]:
- """
- Executes git pull.
- """
+ def _pull(
+ self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
+ token: str = None, force: bool = False, sub_path: str = None,
+ ) -> Tuple[bool, str]:
if not project_path.exists():
- return False, "Project path does not exist"
+ return False, "项目目录不存在"
- auth_url = self._get_auth_url(repo_url, username, token)
-
- # Ensure git init and remote
- self._ensure_git_initialized(project_path, auth_url)
+ sub_path = self._normalize_sub_path(sub_path)
+ auth_env = self._auth_env(username, token)
+ if sub_path:
+ if force:
+ return False, "目录级同步不支持强制拉取"
+ temp_dir, remote_root, message = self._remote_worktree(repo_url, branch, auth_env)
+ if not temp_dir:
+ return False, f"获取远端仓库失败: {message}"
+ try:
+ self._replace_path(remote_root, project_path, sub_path)
+ finally:
+ temp_dir.cleanup()
+ return True, f"已从远端同步目录 [{sub_path}]"
- # Fetch first
- success, msg = self._run_command(["git", "fetch", "origin"], project_path)
- if not success:
- return False, f"Fetch failed: {msg}"
-
+ ok, message = self._ensure_git_initialized(project_path, repo_url)
+ if not ok:
+ return False, message
+ ok, message = self._run_command(["git", "fetch", "origin"], project_path, auth_env)
+ if not ok:
+ return False, f"获取远端更新失败: {message}"
if force:
- # Force Reset to remote
- cmd = ["git", "reset", "--hard", f"origin/{branch}"]
- else:
- # Simple pull
- cmd = ["git", "pull", "origin", branch]
-
- return self._run_command(cmd, project_path)
+ return self._run_command(["git", "reset", "--hard", f"origin/{branch}"], project_path)
+ return self._run_command(["git", "pull", "--ff-only", "origin", branch], project_path, auth_env)
- async def push(self, project_path: Path, repo_url: str, branch: str = "main", username: str = None, token: str = None, force: bool = False) -> Tuple[bool, str]:
- """
- Executes git push.
- """
+ async def pull(self, *args, **kwargs) -> Tuple[bool, str]:
+ """Run pull outside the async event loop."""
+ return await asyncio.to_thread(self._pull, *args, **kwargs)
+
+ def _push(
+ self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
+ token: str = None, force: bool = False, sub_path: str = None,
+ ) -> Tuple[bool, str]:
if not project_path.exists():
- return False, "Project path does not exist"
+ return False, "项目目录不存在"
- auth_url = self._get_auth_url(repo_url, username, token)
-
- # Ensure git init and remote
- self._ensure_git_initialized(project_path, auth_url)
-
- # Add all changes
- self._run_command(["git", "add", "."], project_path)
-
- # Commit if changes exist
- # Check if there are changes to commit
- status_success, status_output = self._run_command(["git", "status", "--porcelain"], project_path)
- if status_success and status_output.strip():
- # Create a commit
- self._run_command(["git", "commit", "-m", "Update from Nex Docus"], project_path)
-
- # Push
- cmd = ["git", "push", "-u", "origin", branch]
+ sub_path = self._normalize_sub_path(sub_path)
+ auth_env = self._auth_env(username, token)
+ if sub_path:
+ if force:
+ return False, "目录级推送不支持强制推送"
+ temp_dir, remote_root, message = self._remote_worktree(repo_url, branch, auth_env)
+ if not temp_dir:
+ return False, f"获取远端仓库失败: {message}"
+ try:
+ self._replace_path(project_path, remote_root, sub_path)
+ ok, message = self._run_command(["git", "add", "-A", "--", sub_path], remote_root)
+ if not ok:
+ return False, f"暂存目录失败: {message}"
+ ok, message, changed = self._commit_if_changed(
+ remote_root, f"Update {sub_path} from NEX Docus"
+ )
+ if not ok:
+ return False, f"提交目录失败: {message}"
+ if not changed:
+ return True, f"目录 [{sub_path}] 没有需要推送的变更"
+ ok, message = self._run_command(
+ ["git", "push", "-u", "origin", branch], remote_root, auth_env
+ )
+ if not ok:
+ return False, f"推送被远端拒绝,请重新拉取该目录后再试: {message}"
+ return True, f"已推送目录 [{sub_path}]"
+ finally:
+ temp_dir.cleanup()
+
+ ok, message = self._ensure_git_initialized(project_path, repo_url)
+ if not ok:
+ return False, message
+ ok, message = self._run_command(["git", "fetch", "origin"], project_path, auth_env)
+ if not ok:
+ return False, f"获取远端更新失败: {message}"
+ if not force:
+ ok, _ = self._run_command(
+ ["git", "merge-base", "--is-ancestor", f"origin/{branch}", "HEAD"], project_path
+ )
+ if not ok:
+ return False, "远端仓库已有未拉取的更新,请先执行整库拉取后再推送"
+ ok, message = self._run_command(["git", "add", "."], project_path)
+ if not ok:
+ return False, f"暂存项目失败: {message}"
+ ok, message, _ = self._commit_if_changed(project_path, "Update from NEX Docus")
+ if not ok:
+ return False, f"提交项目失败: {message}"
+ command = ["git", "push", "-u", "origin", branch]
if force:
- cmd.append("--force")
-
- return self._run_command(cmd, project_path)
+ command.append("--force-with-lease")
+ return self._run_command(command, project_path, auth_env)
+
+ async def push(self, *args, **kwargs) -> Tuple[bool, str]:
+ """Run push outside the async event loop."""
+ return await asyncio.to_thread(self._push, *args, **kwargs)
+
+ def list_remote_tree(
+ self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
+ token: str = None,
+ ) -> Tuple[bool, str]:
+ if not project_path.exists():
+ return False, "项目目录不存在"
+ temp_dir, remote_root, message = self._remote_worktree(
+ repo_url, branch, self._auth_env(username, token)
+ )
+ if not temp_dir:
+ return False, f"获取远端仓库失败: {message}"
+ try:
+ return self._run_command(
+ ["git", "-c", "core.quotepath=false", "ls-tree", "-r", "--name-only", "HEAD"],
+ remote_root,
+ )
+ finally:
+ temp_dir.cleanup()
+
git_service = GitService()
diff --git a/backend/scripts/add_git_repo_sync_path.sql b/backend/scripts/add_git_repo_sync_path.sql
new file mode 100644
index 0000000..65201e6
--- /dev/null
+++ b/backend/scripts/add_git_repo_sync_path.sql
@@ -0,0 +1,3 @@
+-- 为项目Git仓库表增加“同步目录”字段(空=整个仓库)
+ALTER TABLE project_git_repos
+ADD COLUMN sync_path VARCHAR(255) DEFAULT '' COMMENT '同步目录(空=整个仓库)' AFTER is_default;
diff --git a/backend/tests/test_git_service.py b/backend/tests/test_git_service.py
new file mode 100644
index 0000000..c50fb6d
--- /dev/null
+++ b/backend/tests/test_git_service.py
@@ -0,0 +1,78 @@
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from app.services.git_service import GitService
+
+
+def run_git(cwd: Path, *args: str) -> str:
+ result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True)
+ return result.stdout.strip()
+
+
+def commit_file(cwd: Path, relative_path: str, content: str, message: str) -> None:
+ target = cwd / relative_path
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(content, encoding="utf-8")
+ run_git(cwd, "add", ".")
+ run_git(cwd, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", message)
+
+
+@pytest.fixture
+def git_fixture(tmp_path: Path):
+ remote = tmp_path / "remote.git"
+ local = tmp_path / "local"
+ seed = tmp_path / "seed"
+ remote.mkdir()
+ local.mkdir()
+ seed.mkdir()
+ run_git(remote, "init", "--bare", "-b", "main")
+ run_git(seed, "init", "-b", "main")
+ commit_file(seed, "docs/one.md", "one", "initial")
+ commit_file(seed, "other.txt", "other", "other")
+ run_git(seed, "remote", "add", "origin", str(remote))
+ run_git(seed, "push", "-u", "origin", "main")
+ run_git(local, "clone", "-b", "main", str(remote), ".")
+ return remote, local, seed
+
+
+@pytest.mark.asyncio
+async def test_directory_push_only_changes_selected_directory(git_fixture):
+ remote, local, _ = git_fixture
+ service = GitService()
+ (local / "docs/one.md").write_text("updated", encoding="utf-8")
+ (local / "other.txt").write_text("local-only", encoding="utf-8")
+
+ ok, message = await service.push(local, str(remote), sub_path="docs")
+
+ assert ok, message
+ assert run_git(local, "remote", "get-url", "origin") == str(remote)
+ check = Path(local.parent / "check")
+ run_git(local.parent, "clone", "-b", "main", str(remote), str(check))
+ assert (check / "docs/one.md").read_text(encoding="utf-8") == "updated"
+ assert (check / "other.txt").read_text(encoding="utf-8") == "other"
+
+
+@pytest.mark.asyncio
+async def test_whole_push_rejects_remote_ahead(git_fixture):
+ remote, local, seed = git_fixture
+ service = GitService()
+ commit_file(seed, "other.txt", "remote-update", "remote update")
+ run_git(seed, "push", "origin", "main")
+ (local / "docs/one.md").write_text("local-update", encoding="utf-8")
+
+ ok, message = await service.push(local, str(remote))
+
+ assert not ok
+ assert "先执行整库拉取" in message
+
+
+@pytest.mark.asyncio
+async def test_credentials_are_not_written_to_remote_url(git_fixture):
+ remote, local, _ = git_fixture
+ service = GitService()
+ ok, message = await service.pull(local, str(remote), username="user", token="secret")
+
+ assert ok, message
+ assert "secret" not in run_git(local, "remote", "get-url", "origin")
diff --git a/frontend/src/api/project.js b/frontend/src/api/project.js
index a21f62c..e24eef7 100644
--- a/frontend/src/api/project.js
+++ b/frontend/src/api/project.js
@@ -152,22 +152,22 @@ export function removeProjectMember(projectId, userId) {
/**
* Git Pull
*/
-export function gitPull(projectId, repoId = null, force = false) {
+export function gitPull(projectId, repoId = null, force = false, syncScope = null, subPath = '') {
return request({
url: `/projects/${projectId}/git/pull`,
method: 'post',
- params: { repo_id: repoId, force }
+ params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined }
})
}
/**
* Git Push
*/
-export function gitPush(projectId, repoId = null, force = false) {
+export function gitPush(projectId, repoId = null, force = false, syncScope = null, subPath = '') {
return request({
url: `/projects/${projectId}/git/push`,
method: 'post',
- params: { repo_id: repoId, force }
+ params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined }
})
}
@@ -212,3 +212,14 @@ export function deleteGitRepo(projectId, repoId) {
method: 'delete',
})
}
+
+/**
+ * 获取Git仓库远端目录结构(用于选择同步目录)
+ */
+export function getGitRepoDirectories(projectId, repoId = null) {
+ return request({
+ url: `/projects/${projectId}/git/directories`,
+ method: 'post',
+ params: { repo_id: repoId || undefined },
+ })
+}
diff --git a/frontend/src/pages/Document/DocumentPage.css b/frontend/src/pages/Document/DocumentPage.css
index 093586b..7d8bfdb 100644
--- a/frontend/src/pages/Document/DocumentPage.css
+++ b/frontend/src/pages/Document/DocumentPage.css
@@ -192,6 +192,78 @@
gap: 16px;
}
+.docs-breadcrumb-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ min-width: 0;
+ flex: 1;
+}
+
+.docs-breadcrumb {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--text-color);
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.docs-breadcrumb-segment {
+ display: inline-flex;
+ align-items: center;
+ min-width: 0;
+ max-width: 100%;
+}
+
+.docs-breadcrumb-link,
+.docs-breadcrumb-current {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ padding: 4px 6px;
+ border: 0;
+ border-radius: 4px;
+ color: inherit;
+ font: inherit;
+ line-height: 1.4;
+ white-space: nowrap;
+}
+
+.docs-breadcrumb-link {
+ background: transparent;
+ color: var(--text-color-secondary);
+ cursor: pointer;
+}
+
+.docs-breadcrumb-link:hover {
+ background: var(--item-hover-bg);
+ color: var(--link-color);
+}
+
+.docs-breadcrumb-link:focus-visible {
+ outline: 2px solid rgba(22, 119, 255, 0.35);
+ outline-offset: 1px;
+}
+
+.docs-breadcrumb-current {
+ overflow: hidden;
+}
+
+.docs-breadcrumb-current span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.docs-breadcrumb-separator {
+ margin: 0 2px;
+ color: var(--text-color-secondary);
+ font-weight: 400;
+}
+
.docs-header-title {
display: flex;
align-items: center;
@@ -285,44 +357,11 @@
padding: 4px 0 24px;
}
-.docs-folder-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 10px 14px;
- margin-bottom: 16px;
- background: var(--bg-color-secondary);
- border: 1px solid var(--border-color);
- border-radius: 10px;
- color: var(--text-color-secondary);
- font-size: 13px;
- min-width: 0;
-}
-
-.docs-folder-path {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
-}
-
-.docs-folder-path-icon {
- flex: none;
- color: #faad14;
- font-size: 15px;
-}
-
-.docs-folder-path-text {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-weight: 500;
- color: var(--text-color);
-}
-
.docs-folder-counts {
flex: none;
+ color: var(--text-color-secondary);
+ font-size: 14px;
+ font-weight: 400;
white-space: nowrap;
}
diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx
index 21fdb0b..564e44c 100644
--- a/frontend/src/pages/Document/DocumentPage.jsx
+++ b/frontend/src/pages/Document/DocumentPage.jsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
-import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
+import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch, Select, TreeSelect, Radio, Alert } from 'antd'
import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -12,7 +12,7 @@ import Highlighter from 'react-highlight-words'
import Mark from 'mark.js'
import GithubSlugger from 'github-slugger'
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
-import { gitPull, gitPush, getGitRepos } from '@/api/project'
+import { gitPull, gitPush, getGitRepos, getGitRepoDirectories } from '@/api/project'
import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share'
import { searchDocuments } from '@/api/search'
import { markProjectNotificationsRead } from '@/api/notification'
@@ -26,6 +26,7 @@ import './DocumentPage.css'
const { Sider, Content } = Layout
const MAX_TOC_ITEMS = 500
+const ROOT_FOLDER_KEY = '__root__'
// 高亮渲染组件
const HighlightText = ({ text, keyword }) => {
@@ -60,6 +61,14 @@ function DocumentPage() {
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
const [gitRepos, setGitRepos] = useState([])
+ // Git 同步范围弹窗状态
+ const [gitSyncModalVisible, setGitSyncModalVisible] = useState(false)
+ const [gitSyncAction, setGitSyncAction] = useState(null) // 'pull' | 'push'
+ const [gitSyncRepoId, setGitSyncRepoId] = useState(null)
+ const [gitSyncScope, setGitSyncScope] = useState('all') // 'all' | 'dir'
+ const [gitSyncSubPath, setGitSyncSubPath] = useState('')
+ const [gitRemoteDirOptions, setGitRemoteDirOptions] = useState([])
+ const [gitSyncing, setGitSyncing] = useState(false)
const [projectName, setProjectName] = useState('')
const [refreshing, setRefreshing] = useState(false)
@@ -77,21 +86,6 @@ function DocumentPage() {
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
- const getHeaderDisplay = (filePath) => {
- const resolvedPath = selectedNodeKey || filePath || 'README.md'
- const fileName = resolvedPath.split('/').filter(Boolean).pop() || 'README.md'
- const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
- const isFolder = Boolean(selectedNode && !selectedNode.isLeaf)
- const isPdf = fileName.toLowerCase().endsWith('.pdf')
- const FileIcon = isFolder ? FolderOutlined : isPdf ? FilePdfOutlined : FileTextOutlined
-
- return {
- fileName,
- FileIcon,
- isPdf,
- }
- }
-
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
document.startViewTransition(() => navigate(to))
@@ -148,9 +142,27 @@ function DocumentPage() {
}
}
+ const selectRootFolder = ({ syncUrl = true } = {}) => {
+ setSelectedFile('')
+ setSelectedNodeKey(ROOT_FOLDER_KEY)
+ setMarkdownContent('')
+ setTocItems([])
+ setViewMode('folder')
+ setOpenKeys([])
+
+ if (syncUrl) {
+ const nextParams = new URLSearchParams(searchParams)
+ nextParams.delete('file')
+ nextParams.set('selected', ROOT_FOLDER_KEY)
+ setSearchParams(nextParams, { replace: true })
+ }
+ }
+
// 获取文件夹内的子节点(与左侧文件树保持一致:文件夹 + md/pdf 文档)
const getFolderChildren = (folderPath) => {
- const node = folderPath ? findNodeByKey(fileTree, folderPath) : null
+ const node = folderPath === ROOT_FOLDER_KEY
+ ? { key: ROOT_FOLDER_KEY, title: projectName || '根目录', isLeaf: false, children: fileTree }
+ : folderPath ? findNodeByKey(fileTree, folderPath) : null
if (!node || node.isLeaf) return { folders: [], files: [], node }
const children = node.children || []
const folders = children.filter(c => !c.isLeaf)
@@ -160,6 +172,91 @@ function DocumentPage() {
return { folders, files, node }
}
+ const getBreadcrumbFolderPath = () => {
+ if (viewMode === 'folder') {
+ return selectedNodeKey === ROOT_FOLDER_KEY ? ROOT_FOLDER_KEY : selectedNodeKey
+ }
+ const selectedPath = selectedFile || selectedNodeKey || ''
+ const lastSlash = selectedPath.lastIndexOf('/')
+ return lastSlash >= 0 ? selectedPath.substring(0, lastSlash) : ROOT_FOLDER_KEY
+ }
+
+ const getBreadcrumbItems = () => {
+ const folderPath = getBreadcrumbFolderPath()
+ const folderParts = folderPath && folderPath !== ROOT_FOLDER_KEY
+ ? folderPath.split('/').filter(Boolean)
+ : []
+ const items = [{
+ key: ROOT_FOLDER_KEY,
+ label: projectName || '根目录',
+ icon: