from __future__ import annotations from server.status import status_bp import time import re import os import json import shutil import subprocess import sys import tempfile import plistlib from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session from server.auth_helpers import api_login_required, resolve_admin_policy from server.context import with_terminal, attach_user_broadcast from server.state import ( PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE_TTL_SECONDS, PROJECT_MAX_STORAGE_MB, container_manager, user_manager, ) from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE from modules.host_workspace_manager import ( create_host_workspace, delete_host_workspace, load_host_workspace_catalog, rename_host_workspace, resolve_host_workspace, set_default_host_workspace, ) from utils.host_workspace_debug import write_host_workspace_debug from server.utils_common import log_conn_diag import server.state as state def _run_project_git(project_path: Path, args: list[str]) -> tuple[bool, str]: git_bin = shutil.which("git") if not git_bin: return False, "" try: proc = subprocess.run( [git_bin, "-c", "core.quotePath=false", "-C", str(project_path), *args], cwd=str(project_path), text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=2, check=False, ) except Exception: return False, "" return proc.returncode == 0, (proc.stdout or "").strip() def _run_project_git_raw(project_path: Path, args: list[str], timeout: int = 4) -> tuple[bool, str]: git_bin = shutil.which("git") if not git_bin: return False, "" try: proc = subprocess.run( [git_bin, "-c", "core.quotePath=false", "-C", str(project_path), *args], cwd=str(project_path), text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=timeout, check=False, ) except Exception: return False, "" return proc.returncode == 0, (proc.stdout or "") def _sum_git_numstat(numstat_text: str) -> tuple[int, int]: additions = 0 deletions = 0 for raw_line in (numstat_text or "").splitlines(): parts = raw_line.split("\t", 2) if len(parts) < 2: continue if parts[0].isdigit(): additions += int(parts[0]) if parts[1].isdigit(): deletions += int(parts[1]) return additions, deletions def _paths_from_git_numstat(numstat_text: str) -> list[str]: paths: list[str] = [] for raw_line in (numstat_text or "").splitlines(): parts = raw_line.split("\t") if len(parts) < 3: continue path = parts[-1].strip() if path: paths.append(path) return paths def _count_text_lines(text: str) -> int: if text == "": return 0 return len(text.splitlines()) def _git_file_old_line_count(repo_root: Path, rel_path: str) -> int: ok, text = _run_project_git_raw(repo_root, ["show", f"HEAD:{rel_path}"], timeout=3) if ok: return _count_text_lines(text) target = (repo_root / rel_path).resolve() try: if target.exists() and repo_root in target.parents: return _count_text_lines(target.read_text(encoding="utf-8", errors="ignore")) except Exception: pass return 0 def _git_file_lines(repo_root: Path, rel_path: str, ref: str = "HEAD") -> list[str]: ok, text = _run_project_git_raw(repo_root, ["show", f"{ref}:{rel_path}"], timeout=3) if ok: return text.splitlines() return [] def _working_file_lines(repo_root: Path, rel_path: str) -> list[str]: target = (repo_root / rel_path).resolve() try: if target.exists() and repo_root in target.parents: return target.read_text(encoding="utf-8", errors="ignore").splitlines() except Exception: pass return _git_file_lines(repo_root, rel_path) def _parse_hunk_header(header: str) -> tuple[int, int, int, int]: match = re.match(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?", header) if not match: return 0, 0, 0, 0 old_start = int(match.group(1)) old_count = int(match.group(2) or "1") new_start = int(match.group(3)) new_count = int(match.group(4) or "1") return old_start, old_count, new_start, new_count def _parse_git_diff(repo_root: Path, diff_text: str, fold_context_map: dict[str, dict[str, int]] | None = None) -> list[dict]: files: list[dict] = [] current: dict | None = None current_hunk: dict | None = None old_line = 0 new_line = 0 previous_old_end = 0 previous_new_end = 0 def finish_hunk(): nonlocal current_hunk, current, previous_old_end, previous_new_end if not current_hunk or not current: current_hunk = None return path = current.get("path") or current.get("old_path") or "" old_start = current_hunk.get("old_start") or 0 old_count = current_hunk.get("old_count") or 0 new_start = current_hunk.get("new_start") or 0 new_count = current_hunk.get("new_count") or 0 current_hunk["before_hidden"] = max(0, int(old_start) - int(previous_old_end) - 1) before_fold_key = f"before:{old_start}" current_hunk["before_fold_key"] = before_fold_key requested_extra = int((fold_context_map or {}).get(path, {}).get(before_fold_key, 0) or 0) if requested_extra > 0 and current_hunk["before_hidden"] > 0: add_count = min(current_hunk["before_hidden"], requested_extra) old_lines = _git_file_lines(repo_root, current.get("old_path") or path) new_lines = _working_file_lines(repo_root, path) injected = [] for idx in range(add_count): old_no = int(old_start) - add_count + idx new_no = int(new_start) - add_count + idx text = "" if 0 < new_no <= len(new_lines): text = new_lines[new_no - 1] elif 0 < old_no <= len(old_lines): text = old_lines[old_no - 1] injected.append({"kind": "context", "old_line": old_no, "new_line": new_no, "text": text}) current_hunk["lines"] = injected + current_hunk.get("lines", []) current_hunk["before_hidden"] -= add_count previous_old_end = int(old_start) + int(old_count) - 1 previous_new_end = int(new_start) + int(new_count) - 1 current["hunks"].append(current_hunk) current_hunk = None def finish_file(): nonlocal current, previous_old_end, previous_new_end finish_hunk() if not current: return old_total = _git_file_old_line_count(repo_root, current.get("old_path") or current.get("path") or "") current["after_hidden"] = max(0, old_total - int(previous_old_end)) current["after_fold_key"] = "after" path = current.get("path") or current.get("old_path") or "" requested_extra = int((fold_context_map or {}).get(path, {}).get("after", 0) or 0) if requested_extra > 0 and current["after_hidden"] > 0 and current.get("hunks"): add_count = min(current["after_hidden"], requested_extra) old_lines = _git_file_lines(repo_root, current.get("old_path") or path) new_lines = _working_file_lines(repo_root, path) injected = [] for idx in range(add_count): old_no = int(previous_old_end) + 1 + idx new_no = int(previous_new_end) + 1 + idx text = "" if 0 < new_no <= len(new_lines): text = new_lines[new_no - 1] elif 0 < old_no <= len(old_lines): text = old_lines[old_no - 1] injected.append({"kind": "context", "old_line": old_no, "new_line": new_no, "text": text}) current["hunks"][-1]["lines"].extend(injected) current["after_hidden"] -= add_count files.append(current) current = None previous_old_end = 0 previous_new_end = 0 for raw in diff_text.splitlines(): if raw.startswith("diff --git "): finish_file() current = { "path": "", "old_path": "", "additions": 0, "deletions": 0, "hunks": [], "after_hidden": 0, } previous_old_end = 0 continue if current is None: continue if raw.startswith("--- "): old_path = raw[4:].strip() current["old_path"] = "" if old_path == "/dev/null" else re.sub(r"^a/", "", old_path) continue if raw.startswith("+++ "): new_path = raw[4:].strip() current["path"] = current["old_path"] if new_path == "/dev/null" else re.sub(r"^b/", "", new_path) continue if raw.startswith("@@ "): finish_hunk() old_start, old_count, new_start, new_count = _parse_hunk_header(raw) current_hunk = { "header": raw, "old_start": old_start, "old_count": old_count, "new_start": new_start, "new_count": new_count, "before_hidden": 0, "lines": [], } old_line = old_start new_line = new_start continue if current_hunk is None: continue marker = raw[:1] if marker not in {" ", "+", "-"}: continue text = raw[1:] line = {"kind": "context", "old_line": old_line, "new_line": new_line, "text": text} if marker == "+": line = {"kind": "add", "old_line": None, "new_line": new_line, "text": text} new_line += 1 current["additions"] += 1 elif marker == "-": line = {"kind": "delete", "old_line": old_line, "new_line": None, "text": text} old_line += 1 current["deletions"] += 1 else: old_line += 1 new_line += 1 current_hunk["lines"].append(line) finish_file() return [item for item in files if item.get("path") and item.get("hunks")] @status_bp.route('/api/project/git-summary') @api_login_required @with_terminal def get_project_git_summary(terminal, workspace, username): """返回当前项目自身 git 仓库概览(不使用隐藏版本管理 git)。""" project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve() if not project_path.exists(): return jsonify({"success": True, "data": {"has_git": False}}) ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"]) if not ok or not root_text: return jsonify({"success": True, "data": {"has_git": False}}) repo_root = Path(root_text).expanduser().resolve() ok, branch = _run_project_git(repo_root, ["branch", "--show-current"]) if not ok or not branch: ok, branch = _run_project_git(repo_root, ["rev-parse", "--short", "HEAD"]) branch = f"HEAD {branch}" if ok and branch else "HEAD" _, upstream = _run_project_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) _, numstat_text = _run_project_git(repo_root, ["diff", "--numstat", "HEAD", "--"]) additions, deletions = _sum_git_numstat(numstat_text) return jsonify({ "success": True, "data": { "has_git": True, "project_name": repo_root.name, "project_path": str(repo_root), "branch": branch, "upstream": upstream, "additions": additions, "deletions": deletions, } }) @status_bp.route('/api/project/git-diff') @api_login_required @with_terminal def get_project_git_diff(terminal, workspace, username): """返回当前项目自身 git 工作区 diff,用于前端侧边栏展示。""" project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve() if not project_path.exists(): return jsonify({"success": True, "data": {"has_git": False, "files": []}}) ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"]) if not ok or not root_text: return jsonify({"success": True, "data": {"has_git": False, "files": []}}) repo_root = Path(root_text).expanduser().resolve() try: context_lines = int(request.args.get("context", "3") or 3) except Exception: context_lines = 3 context_lines = max(0, min(200, context_lines)) context_map: dict[str, int] = {} raw_context_map = request.args.get("context_map", "").strip() if raw_context_map: try: parsed = json.loads(raw_context_map) if isinstance(parsed, dict): for key, value in parsed.items(): if not isinstance(key, str) or not key: continue try: context_map[key] = max(0, min(200, int(value))) except Exception: continue except Exception: context_map = {} fold_context_map: dict[str, dict[str, int]] = {} raw_fold_context_map = request.args.get("fold_context_map", "").strip() if raw_fold_context_map: try: parsed = json.loads(raw_fold_context_map) if isinstance(parsed, dict): for path_key, folds in parsed.items(): if not isinstance(path_key, str) or not isinstance(folds, dict): continue safe_folds: dict[str, int] = {} for fold_key, value in folds.items(): if not isinstance(fold_key, str) or not fold_key: continue try: safe_folds[fold_key] = max(0, min(200, int(value))) except Exception: continue if safe_folds: fold_context_map[path_key] = safe_folds except Exception: fold_context_map = {} ok, branch = _run_project_git(repo_root, ["branch", "--show-current"]) if not ok or not branch: ok, branch = _run_project_git(repo_root, ["rev-parse", "--short", "HEAD"]) branch = f"HEAD {branch}" if ok and branch else "HEAD" _, upstream = _run_project_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) _, numstat_text = _run_project_git(repo_root, ["diff", "--numstat", "HEAD", "--"]) additions, deletions = _sum_git_numstat(numstat_text) diff_text = "" if context_map: parts: list[str] = [] for rel_path in _paths_from_git_numstat(numstat_text): per_file_context = context_map.get(rel_path, context_lines) ok, file_diff = _run_project_git_raw( repo_root, ["--no-pager", "diff", f"--unified={per_file_context}", "HEAD", "--", rel_path], timeout=8, ) if ok and file_diff: parts.append(file_diff) diff_text = "\n".join(parts) else: ok, diff_text = _run_project_git_raw(repo_root, [f"--no-pager", "diff", f"--unified={context_lines}", "HEAD", "--"], timeout=8) if not ok: diff_text = "" return jsonify({ "success": True, "data": { "has_git": True, "project_name": repo_root.name, "project_path": str(repo_root), "branch": branch, "upstream": upstream, "additions": additions, "deletions": deletions, "context": context_lines, "files": _parse_git_diff(repo_root, diff_text, fold_context_map=fold_context_map), } })