diff --git a/INSTALL.md b/INSTALL.md index c8a5d50..84493ab 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -139,21 +139,32 @@ docker run -d \ -v ./data:/app/data \ -v /Users/you/Development:/Users/you/Development:ro \ -e ANTHROPIC_API_KEY=your_key_here \ + -e PROJECTS_DIR=/Users/you/Development \ ericblue/vibefocus:latest ``` Replace `/Users/you/Development` with the parent directory where your git repos live. The path inside the container must match the host path so that `local_path` values on your projects resolve correctly. The `:ro` flag makes it read-only. -If using `docker-compose.yml`, uncomment the volume mount line and set your path: +If using `docker-compose.yml`, set `PROJECTS_DIR` before starting the container: -```yaml -volumes: - - ./data:/app/data - - /Users/you/Development:/Users/you/Development:ro +```bash +PROJECTS_DIR=/Users/you/Development docker compose up -d --build ``` Without this mount, VibeFocus still works but analytics (heatmaps, velocity, streaks) will be empty and code analysis won't be available. +### Importing Existing Local Repos + +After startup, open Settings and use **Import Local Git Repositories**. Enter the parent folder that contains your repos, for example `/Users/you/Development`, then click **Scan Repositories**. VibeFocus creates missing projects, updates matching projects by local path or GitHub URL, infers GitHub URLs from `origin`, and refreshes lightweight git stats like branch, dirty status, and latest commit. + +If the Settings page says the directory is missing, the backend cannot see that path. For Docker, restart with the folder mounted: + +```bash +PROJECTS_DIR=/Users/you/Development docker compose up -d --build +``` + +From source, set `PROJECTS_DIR` in `backend/.env` or enter the absolute path in Settings. Re-running a scan is safe; it updates existing projects without importing commit logs. Use Analytics sync later when you want heatmaps, velocity, streaks, and health history. + ### From Source ```bash diff --git a/Makefile b/Makefile index 2bf36c3..53127a1 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ MCP_VENV := $(MCP_DIR)/venv BE_PORT ?= 8000 FE_PORT ?= 5173 PYTHON ?= /opt/homebrew/bin/python3.12 +PROJECTS_DIR ?= # ── Version (single source of truth: VERSION file) ────────────────────────── CURRENT_VERSION := $(shell cat VERSION 2>/dev/null || echo "0.0.0") @@ -20,7 +21,7 @@ DOCKER_TAG ?= $(CURRENT_VERSION) # ── Install / Setup ────────────────────────────────────────────────────────── -.PHONY: install install-fe install-be install-mcp +.PHONY: install install-fe install-be install-mcp import-projects install: install-fe install-be install-mcp ## Install all dependencies @@ -33,6 +34,13 @@ install-be: ## Create venv and install backend dependencies install-mcp: ## Create venv and install MCP server dependencies cd $(MCP_DIR) && $(PYTHON) -m venv venv && . venv/bin/activate && pip install -r requirements.txt +import-projects: ## Import local git repos into VibeFocus (PROJECTS_DIR=/path/to/repos) + @if [ -z "$(PROJECTS_DIR)" ]; then \ + echo "PROJECTS_DIR is required. Example: make import-projects PROJECTS_DIR=/path/to/repos"; \ + exit 1; \ + fi + cd $(BE_DIR) && . venv/bin/activate && python import_local_projects.py --root "$(PROJECTS_DIR)" + # ── Frontend ───────────────────────────────────────────────────────────────── .PHONY: fe fe-build fe-stop diff --git a/README.md b/README.md index 663925f..ac88a29 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,9 @@ docker run -d -p 8000:8000 \ ericblue/vibefocus:latest ``` -Open http://localhost:8000. The volume mount to your projects directory enables git sync, code analysis, and AI code exploration. +Open http://localhost:8000. The volume mount to your projects directory enables local repo import, git sync, code analysis, and AI code exploration. + +On a fresh install, open Settings and use **Import Local Git Repositories** to scan the folder you mounted or configured. The scan creates projects and refreshes lightweight git stats only; use Analytics sync later when you want commit history. ### From Source diff --git a/backend/.env.example b/backend/.env.example index 98e6452..e2fbce2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,3 +2,6 @@ ANTHROPIC_API_KEY=your_api_key_here DATABASE_URL=sqlite:///./vibefocus.db CORS_ORIGINS=http://localhost:5173 PORT=8000 + +# Optional: parent folder containing local git repositories. +# PROJECTS_DIR=~/Development diff --git a/backend/database.py b/backend/database.py index 5736dae..2013e7a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -4,10 +4,11 @@ class Settings(BaseSettings): - anthropic_api_key: str + anthropic_api_key: str | None = None database_url: str = "sqlite:///./vibefocus.db" cors_origins: str = "http://localhost:5173" port: int = 8000 + projects_dir: str | None = None model_config = SettingsConfigDict(env_file=".env", extra="ignore") diff --git a/backend/import_local_projects.py b/backend/import_local_projects.py new file mode 100644 index 0000000..fe84a42 --- /dev/null +++ b/backend/import_local_projects.py @@ -0,0 +1,311 @@ +""" +Import local git repositories into VibeFocus. + +Usage: + cd backend + python import_local_projects.py --root /Users/you/Development +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable + +from database import Base, SessionLocal, engine +from models import Bucket, Project, State +from services.git_service import get_local_git_stats + + +DEFAULT_STATES = [ + {"name": "Idea", "color": "#8b5cf6", "position": 0}, + {"name": "Exploring", "color": "#0ea5e9", "position": 1}, + {"name": "Building", "color": "#f59e0b", "position": 2}, + {"name": "MVP", "color": "#f97316", "position": 3}, + {"name": "Launched", "color": "#22c55e", "position": 4}, + {"name": "Stalled", "color": "#ef4444", "position": 5}, + {"name": "Archived", "color": "#64748b", "position": 6}, +] + +DEFAULT_BUCKETS = [ + {"name": "Uncategorized", "color": "#94a3b8", "position": 0}, + {"name": "Open Source", "color": "#0ea5e9", "position": 1}, + {"name": "Commercial", "color": "#f59e0b", "position": 2}, + {"name": "Personal", "color": "#ec4899", "position": 3}, + {"name": "Side Project", "color": "#8b5cf6", "position": 4}, + {"name": "Client Work", "color": "#10b981", "position": 5}, + {"name": "Experiment", "color": "#f97316", "position": 6}, +] + + +def run(repo: Path, args: list[str]) -> str: + result = subprocess.run(args, cwd=repo, capture_output=True, text=True, timeout=10) + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def utcnow_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def find_git_repos(root: Path, recursive: bool) -> Iterable[Path]: + if (root / ".git").is_dir(): + yield root + if not recursive: + return + + if recursive: + seen = {root.resolve()} if (root / ".git").is_dir() else set() + for git_dir in root.rglob(".git"): + if git_dir.is_dir(): + repo = git_dir.parent.resolve() + if repo not in seen: + seen.add(repo) + yield repo + return + + for child in sorted(root.iterdir()): + if child.is_dir() and (child / ".git").is_dir(): + yield child + + +def normalize_github_url(remote: str) -> str | None: + if not remote: + return None + remote = remote.removesuffix(".git") + ssh_match = re.match(r"git@github\.com:([^/]+)/(.+)$", remote) + if ssh_match: + return f"https://github.com/{ssh_match.group(1)}/{ssh_match.group(2)}" + https_match = re.match(r"https://github\.com/([^/]+)/(.+)$", remote) + if https_match: + return remote + return None + + +def title_from_name(name: str) -> str: + special = {"pq": "PQ", "api": "API", "mcp": "MCP", "sdk": "SDK"} + parts = re.split(r"[-_\s]+", name) + return " ".join(special.get(part.lower(), part.capitalize()) for part in parts if part) + + +def read_description(repo: Path) -> str: + package_json = repo / "package.json" + if package_json.exists(): + try: + description = json.loads(package_json.read_text()).get("description") + if description: + return description.strip() + except (OSError, json.JSONDecodeError): + pass + + for name in ("README.md", "readme.md", "README"): + readme = repo / name + if not readme.exists(): + continue + try: + lines = [line.strip() for line in readme.read_text(errors="replace").splitlines()] + except OSError: + continue + for line in lines: + if not line or line.startswith("#") or line.startswith("[!"): + continue + return line[:500] + return "" + + +def detect_stack(repo: Path) -> list[str]: + stack: list[str] = [] + + def add(name: str): + if name not in stack: + stack.append(name) + + if (repo / "package.json").exists(): + add("JavaScript/TypeScript") + try: + package = json.loads((repo / "package.json").read_text()) + deps = {**package.get("dependencies", {}), **package.get("devDependencies", {})} + if "next" in deps: + add("Next.js") + if "react" in deps: + add("React") + if "vite" in deps: + add("Vite") + if "tailwindcss" in deps: + add("Tailwind CSS") + if "prisma" in deps: + add("Prisma") + except (OSError, json.JSONDecodeError): + pass + if (repo / "pyproject.toml").exists() or (repo / "requirements.txt").exists(): + add("Python") + if (repo / "composer.json").exists(): + add("PHP") + if (repo / "Gemfile").exists(): + add("Ruby") + if (repo / "go.mod").exists(): + add("Go") + if (repo / "Cargo.toml").exists(): + add("Rust") + if (repo / "Dockerfile").exists() or (repo / "docker-compose.yml").exists(): + add("Docker") + + return stack + + +def parse_last_commit_at(repo: Path) -> datetime | None: + raw = run(repo, ["git", "log", "-1", "--format=%cI"]) + if not raw: + return None + try: + return datetime.fromisoformat(raw) + except ValueError: + return None + + +def state_for(last_commit_at: datetime | None, states: dict[str, str]) -> str: + if not last_commit_at: + return states["Building"] + now = datetime.now(last_commit_at.tzinfo or timezone.utc) + age_days = (now - last_commit_at).days + if age_days <= 45: + return states["Building"] + if age_days <= 120: + return states["MVP"] + return states["Stalled"] + + +def priority_for(last_commit_at: datetime | None) -> str: + if not last_commit_at: + return "medium" + now = datetime.now(last_commit_at.tzinfo or timezone.utc) + age_days = (now - last_commit_at).days + if age_days <= 14: + return "high" + if age_days <= 90: + return "medium" + return "low" + + +def bucket_for(buckets: dict[str, str]) -> str: + return buckets["Uncategorized"] + + +def ensure_defaults(db) -> tuple[dict[str, str], dict[str, str]]: + Base.metadata.create_all(bind=engine) + for bucket in DEFAULT_BUCKETS: + if not db.query(Bucket).filter(Bucket.name == bucket["name"]).first(): + db.add(Bucket(**bucket)) + for state in DEFAULT_STATES: + if not db.query(State).filter(State.name == state["name"]).first(): + db.add(State(**state)) + db.commit() + buckets = {b.name: b.id for b in db.query(Bucket).all()} + states = {s.name: s.id for s in db.query(State).all()} + return buckets, states + + +def import_repo(db, repo: Path, buckets: dict[str, str], states: dict[str, str]) -> tuple[str, str]: + remote = run(repo, ["git", "remote", "get-url", "origin"]) + github_url = normalize_github_url(remote) + local_path = str(repo.resolve()) + last_commit_at = parse_last_commit_at(repo) + + project = ( + db.query(Project).filter(Project.local_path == local_path).first() + or (db.query(Project).filter(Project.github_url == github_url).first() if github_url else None) + or db.query(Project).filter(Project.name == title_from_name(repo.name)).first() + ) + created = project is None + if created: + project = Project( + name=title_from_name(repo.name), + bucket_id=bucket_for(buckets), + state_id=state_for(last_commit_at, states), + priority=priority_for(last_commit_at), + kanban_position=db.query(Project).count(), + ) + db.add(project) + + project.description = project.description or read_description(repo) + project.github_url = project.github_url or github_url + project.local_path = local_path + project.code_tech_stack = project.code_tech_stack or detect_stack(repo) + project.code_summary = project.code_summary or f"Imported from local git repository at {local_path}." + + for field, value in get_local_git_stats(local_path).items(): + setattr(project, field, value) + project.stats_updated_at = utcnow_naive() + project.updated_at = utcnow_naive() + + db.commit() + return ("created" if created else "updated", project.name) + + +def scan_repos(db, root: Path, recursive: bool = False) -> dict: + """Scan a directory for git repos and import/update them. Returns a summary dict.""" + buckets, states = ensure_defaults(db) + repos = list(find_git_repos(root, recursive=recursive)) + + results = [] + total_created = total_updated = total_skipped = 0 + for repo in repos: + try: + action, name = import_repo(db, repo, buckets, states) + except Exception as exc: + db.rollback() + total_skipped += 1 + results.append({ + "action": "skipped", + "name": repo.name, + "path": str(repo), + "error": str(exc), + }) + continue + + results.append({"action": action, "name": name, "path": str(repo)}) + if action == "created": + total_created += 1 + else: + total_updated += 1 + + return { + "root": str(root), + "total": len(repos), + "created": total_created, + "updated": total_updated, + "skipped": total_skipped, + "projects": results, + } + + +def main(): + parser = argparse.ArgumentParser(description="Import local git repositories into VibeFocus.") + parser.add_argument("--root", required=True, help="Directory containing local git repositories.") + parser.add_argument("--recursive", action="store_true", help="Search recursively for .git directories.") + args = parser.parse_args() + + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Root directory does not exist: {root}") + + with SessionLocal() as db: + result = scan_repos(db, root, recursive=args.recursive) + + if result["total"] == 0: + raise SystemExit(f"No git repositories found under {root}") + + for project in result["projects"]: + suffix = f" ({project['error']})" if project.get("error") else "" + print(f"{project['action']:7} {project['name']}{suffix}") + print("") + print(f"Imported {result['total']} repos: {result['created']} created, {result['updated']} updated, {result['skipped']} skipped.") + + +if __name__ == "__main__": + main() diff --git a/backend/routers/data.py b/backend/routers/data.py index 6457735..6eee183 100644 --- a/backend/routers/data.py +++ b/backend/routers/data.py @@ -1,6 +1,6 @@ """ Data import/export endpoints for VibeFocus. -Supports JSON (full/per-project), CSV, SQLite backup, and JSON import. +Supports JSON (full/per-project), CSV, SQLite backup, JSON import, and local repo scanning. """ import csv @@ -440,3 +440,54 @@ async def import_preview(file: UploadFile = File(...)): }, "projects": [{"name": p["name"], "completion_pct": p.get("completion_pct", 0)} for p in projects], } + + +# ── Local repo scan ─────────────────────────────────────────────────────────── + +@router.get("/scan-config") +def scan_config(): + """Return the configured PROJECTS_DIR and whether it is ready to scan.""" + raw = settings.projects_dir + resolved = str(Path(raw).expanduser().resolve()) if raw else None + exists = Path(resolved).is_dir() if resolved else False + return { + "projects_dir": resolved, + "projects_dir_raw": raw, + "exists": exists, + "configured": bool(raw), + "ready": bool(raw and exists), + } + + +@router.post("/scan") +def scan_local_projects( + root: str | None = Query(default=None, description="Directory to scan. Falls back to PROJECTS_DIR setting."), + recursive: bool = Query(default=False), + db: Session = Depends(get_db), +): + """Scan a local directory for git repositories and import/update projects.""" + from import_local_projects import scan_repos + + scan_root = root or settings.projects_dir + if not scan_root: + raise HTTPException( + 400, + { + "code": "scan_root_required", + "message": "Enter a directory path to scan or set PROJECTS_DIR in backend/.env.", + }, + ) + + root_path = Path(scan_root).expanduser().resolve() + if not root_path.is_dir(): + raise HTTPException( + 400, + { + "code": "scan_root_missing", + "message": f"Directory not found: {scan_root}", + "root": scan_root, + "resolved_root": str(root_path), + }, + ) + + return scan_repos(db, root_path, recursive=recursive) diff --git a/docker-compose.yml b/docker-compose.yml index a40f480..81e5bd7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - ./backend/.env environment: - DATABASE_URL=sqlite:///./data/vibefocus.db + - PROJECTS_DIR=${PROJECTS_DIR:-} volumes: - ./data:/app/data # Mount local repos (read-only) for git sync, code analysis, and AI chat. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8053101..802621f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { useProjects, useBuckets } from './hooks/useProjects' +import { useProjects, useBuckets, useProject } from './hooks/useProjects' import { useAppStore } from './store/appStore' import { Header } from './components/Header' import { Dashboard } from './components/Dashboard' @@ -26,9 +26,12 @@ export default function App() { return () => window.removeEventListener('hashchange', onHashChange) }, []) const { data: projects = [], isLoading, isError } = useProjects() + const { data: drawerProjectById, isError: drawerProjectMissing } = useProject(drawerProjectId) const { data: buckets = [] } = useBuckets() - const drawerProject = drawerProjectId ? projects.find(p => p.id === drawerProjectId) : null + const drawerProject = drawerProjectId + ? projects.find(p => p.id === drawerProjectId) ?? drawerProjectById ?? null + : null if (isError) { return ( @@ -68,8 +71,8 @@ export default function App() { onClick={closeDrawer} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', - zIndex: 100, opacity: drawerProject ? 1 : 0, - transition: 'opacity 0.2s', pointerEvents: drawerProject ? 'auto' : 'none', + zIndex: 100, opacity: 1, + transition: 'opacity 0.2s', pointerEvents: 'auto', }} />
+ Scanning imports local paths, GitHub remotes, and git stats without requiring an AI key. +