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: , + onClick: () => selectRootFolder(), + }] + + let currentPath = '' + folderParts.forEach((part) => { + currentPath = currentPath ? `${currentPath}/${part}` : part + const path = currentPath + items.push({ + key: path, + label: part, + icon: , + onClick: () => selectFolder(path, { syncUrl: true }), + }) + }) + + if (viewMode !== 'folder' && selectedFile) { + const fileName = selectedFile.split('/').filter(Boolean).pop() + const isPdf = fileName.toLowerCase().endsWith('.pdf') + items.push({ + key: selectedFile, + label: isPdf ? fileName : fileName.replace(/\.md$/i, ''), + icon: isPdf ? : , + }) + } + return items + } + + const renderContentBreadcrumb = () => { + const items = getBreadcrumbItems() + const { folders, files } = getFolderChildren(getBreadcrumbFolderPath()) + + return ( +
+ + {viewMode === 'folder' && ( +
+ {folders.length} 个文件夹 · {files.length} 个文档 +
+ )} +
+ ) + } + const openDocumentPath = (filePath, { syncUrl = false } = {}) => { setSelectedFile(filePath) setSelectedNodeKey(filePath) @@ -196,6 +293,7 @@ function DocumentPage() { useEffect(() => { loadFileTree() + loadGitRepos() }, [projectId]) // 打开项目后,将该项目的未读更新通知标记为已读(与消息通知联动) @@ -246,6 +344,12 @@ function DocumentPage() { // 处理文件加载 if (selectedParam) { + if (selectedParam === ROOT_FOLDER_KEY) { + if (selectedNodeKey !== ROOT_FOLDER_KEY) { + selectRootFolder({ syncUrl: false }) + } + return + } const targetNode = findNodeByKey(fileTree, selectedParam) if (targetNode && !targetNode.isLeaf && selectedParam !== selectedNodeKey) { selectFolder(selectedParam) @@ -737,13 +841,13 @@ function DocumentPage() { } } - const handleGitPull = async (repoId = null, force = false) => { + const handleGitPull = async (repoId = null, force = false, syncScope = null, subPath = '') => { if (gitRepos.length === 0) { message.warning('未配置Git仓库') - return + return false } try { - const res = await gitPull(projectId, repoId, force) + const res = await gitPull(projectId, repoId, force, syncScope, subPath) message.success(res.message || 'Git Pull 成功') // Refresh tree loadFileTree() @@ -751,11 +855,13 @@ function DocumentPage() { if (selectedFile) { loadMarkdown(selectedFile) } + return true } catch (error) { console.error('Git Pull error:', error) const errorMsg = error.response?.data?.detail || 'Git Pull 失败' - - if (!force) { + + // 目录级同步不提供强制重置(强制重置作用于整个仓库,与目录同步语义冲突) + if (!force && !subPath) { Modal.confirm({ title: 'Git Pull 失败', content: ( @@ -772,28 +878,30 @@ function DocumentPage() { okText: '强制重置', okType: 'danger', cancelText: '取消', - onOk: () => handleGitPull(repoId, true) + onOk: () => handleGitPull(repoId, true, 'all') }) - return + return false } - + message.error(errorMsg) + return false } } - const handleGitPush = async (repoId = null, force = false) => { + const handleGitPush = async (repoId = null, force = false, syncScope = null, subPath = '') => { if (gitRepos.length === 0) { message.warning('未配置Git仓库') - return + return false } try { - const res = await gitPush(projectId, repoId, force) + const res = await gitPush(projectId, repoId, force, syncScope, subPath) message.success(res.message || 'Git Push 成功') + return true } catch (error) { console.error('Git Push error:', error) const errorMsg = error.response?.data?.detail || 'Git Push 失败' - - if (!force) { + + if (!force && !subPath) { Modal.confirm({ title: 'Git Push 失败', content: ( @@ -810,89 +918,117 @@ function DocumentPage() { okText: '强制推送', okType: 'danger', cancelText: '取消', - onOk: () => handleGitPush(repoId, true) + onOk: () => handleGitPush(repoId, true, 'all') }) - return + return false } - + message.error(errorMsg) + return false + } + } + + // 构建项目目录树选项(仅文件夹,用于选择同步目录) + const gitDirTreeData = useMemo(() => { + const build = (nodes) => (nodes || []) + .filter(n => !n.isLeaf) + .map(n => ({ + title: n.title, + value: n.key, + children: n.children ? build(n.children) : [], + })) + return build(fileTree) + }, [fileTree]) + + // 加载指定仓库的远端目录结构(用于选择同步目录) + const loadGitRemoteDirs = (repoId = null) => { + getGitRepoDirectories(projectId, repoId) + .then((res) => setGitRemoteDirOptions(res.data?.directories || [])) + .catch(() => setGitRemoteDirOptions([])) + } + + // 打开 Git 同步范围弹窗 + const openGitSyncModal = (action, repoId = null) => { + const targetRepo = repoId + ? gitRepos.find(r => r.id === repoId) + : (gitRepos.find(r => r.is_default === 1) || gitRepos[0]) + setGitSyncAction(action) + setGitSyncRepoId(targetRepo?.id || null) + setGitSyncScope(targetRepo?.sync_path ? 'dir' : 'all') + setGitSyncSubPath(targetRepo?.sync_path || '') + setGitSyncModalVisible(true) + // 加载该仓库远端目录结构(覆盖本地未拉取时的空树) + loadGitRemoteDirs(targetRepo?.id) + } + + // 确认执行同步 + const confirmGitSync = async () => { + const subPath = gitSyncScope === 'dir' ? (gitSyncSubPath || '') : '' + setGitSyncing(true) + try { + const syncScope = gitSyncScope === 'dir' ? 'path' : 'all' + const success = gitSyncAction === 'pull' + ? await handleGitPull(gitSyncRepoId || undefined, false, syncScope, subPath) + : await handleGitPush(gitSyncRepoId || undefined, false, syncScope, subPath) + if (success) { + setGitSyncModalVisible(false) + } + } finally { + setGitSyncing(false) } } const renderGitActions = () => { + const pullButton = ( + + -

{projectName}

@@ -1236,39 +1371,27 @@ function DocumentPage() { {/* 右侧内容区 */} -
- {(() => { - const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile) - return ( - <> -
- - - {fileName} - -
- {viewMode === 'pdf' &&
} - {viewMode === 'markdown' && ( - - - - - )} - - ) - })()} +
+ {renderContentBreadcrumb()} + {viewMode === 'pdf' &&
} + {viewMode === 'markdown' && ( + + + + + )}
{loading ? ( @@ -1297,16 +1420,6 @@ function DocumentPage() { const total = folders.length + files.length return (
-
-
- - {node.key || '根目录'} -
- - {folders.length} 个文件夹 · {files.length} 个文档 - -
- {total === 0 ? ( + + {/* Git 同步范围弹窗 */} + setGitSyncModalVisible(false)} + width={460} + footer={[ + , + , + , + ]} + > + + {gitRepos.length > 1 && ( +
+
Git 仓库
+