From 3788ed7116829859c50dc5d214f1bde503b848a6 Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Sun, 13 Sep 2026 03:55:30 -0500 Subject: [PATCH] feat: add worktree operations and project_dir hot-swap --- src/tinyleaf/git_ops.py | 83 +++++++++++++++++++++++++++++++++++++++- src/tinyleaf/handlers.py | 43 +++++++++++++++++++++ src/tinyleaf/server.py | 28 ++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/tinyleaf/git_ops.py b/src/tinyleaf/git_ops.py index 66e7ea7..662e3e7 100644 --- a/src/tinyleaf/git_ops.py +++ b/src/tinyleaf/git_ops.py @@ -22,8 +22,9 @@ def _run_git(project_dir, *args): def has_git(project_dir): - """Check if a directory is a git repository.""" - return os.path.isdir(os.path.join(project_dir, ".git")) + """Check if a directory is a git repository or worktree.""" + git_path = os.path.join(project_dir, ".git") + return os.path.isdir(git_path) or os.path.isfile(git_path) def list_branches(project_dir): @@ -283,3 +284,81 @@ def show_commit(project_dir, commit_hash): return "" rc, out, _ = _run_git(project_dir, "show", "--format=", "--patch", "--", commit_hash) return out if rc == 0 else "" + + +def worktree_list(project_dir): + """List all worktrees for the repository. + + Returns: + List of dicts with path, branch, head, and is_main for each worktree. + """ + if not has_git(project_dir): + return [] + + rc, out, _ = _run_git(project_dir, "worktree", "list", "--porcelain") + if rc != 0: + return [] + + worktrees = [] + current = {} + for line in out.split("\n"): + if line.startswith("worktree "): + current = {"path": line[9:]} + elif line.startswith("HEAD "): + current["head"] = line[5:13] + elif line.startswith("branch "): + ref = line[7:] + current["branch"] = ref.removeprefix("refs/heads/") + elif line == "detached": + current["branch"] = "(detached)" + elif line == "" and current: + current.setdefault("head", "") + current.setdefault("branch", "") + current["is_main"] = len(worktrees) == 0 + worktrees.append(current) + current = {} + + return worktrees + + +def worktree_add(project_dir, path, branch): + """Create a new worktree for an existing branch. + + Args: + project_dir: Project directory (can be any worktree of the repo). + path: Absolute path for the new worktree directory. + branch: Branch to check out in the worktree. + + Returns: + Dict with success, path, and message. + """ + if not has_git(project_dir): + return {"success": False, "path": "", "message": "Not a git repository"} + + rc, out, err = _run_git(project_dir, "worktree", "add", path, branch) + if rc != 0: + return {"success": False, "path": "", "message": err or out} + return {"success": True, "path": os.path.abspath(path), "message": (out + err).strip()} + + +def worktree_remove(project_dir, path, force=False): + """Remove a worktree. + + Args: + project_dir: Project directory. + path: Path of the worktree to remove. + force: Force removal even with uncommitted changes. + + Returns: + Dict with success status and message. + """ + if not has_git(project_dir): + return {"success": False, "message": "Not a git repository"} + + args = ["worktree", "remove", path] + if force: + args.append("--force") + rc, out, err = _run_git(project_dir, *args) + if rc != 0: + return {"success": False, "message": err or out} + return {"success": True, "message": (out + err).strip()} diff --git a/src/tinyleaf/handlers.py b/src/tinyleaf/handlers.py index 3aa9fd5..cf4bbbb 100644 --- a/src/tinyleaf/handlers.py +++ b/src/tinyleaf/handlers.py @@ -1196,3 +1196,46 @@ def handle_git_pull(config, name): def handle_git_log(config, name): project_dir = _get_project_dir(config, name) return git_ops.log(project_dir) + + +# ── Git Worktrees ── + + +def handle_git_worktree_list(config, name): + project_dir = _get_project_dir(config, name) + return git_ops.worktree_list(project_dir) + + +def handle_git_worktree_add(body, config, name): + project_dir = _get_project_dir(config, name) + branch = body.get("branch", "").strip() + if not branch: + abort(400, "Branch name required") + # Default path: sibling directory named -- + wt_name = body.get("name", "").strip() or branch.replace("/", "-") + parent_dir = os.path.dirname(os.path.abspath(project_dir)) + project_basename = os.path.basename(os.path.abspath(project_dir)) + wt_path = os.path.join(parent_dir, f"{project_basename}--{wt_name}") + return git_ops.worktree_add(project_dir, wt_path, branch) + + +def handle_git_worktree_remove(body, config, name): + project_dir = _get_project_dir(config, name) + path = body.get("path", "").strip() + if not path: + abort(400, "Worktree path required") + force = body.get("force", False) + return git_ops.worktree_remove(project_dir, path, force=force) + + +def handle_git_worktree_switch(body, config, name): + """Switch the active project to a different worktree path.""" + path = body.get("path", "").strip() + if not path: + abort(400, "Worktree path required") + if not os.path.isdir(path): + abort(404, f"Worktree directory not found: {path}") + + if config["mode"] == "single": + config["project_path"] = path + return {"success": True, "path": path} diff --git a/src/tinyleaf/server.py b/src/tinyleaf/server.py index 97f252b..71f2074 100644 --- a/src/tinyleaf/server.py +++ b/src/tinyleaf/server.py @@ -450,6 +450,34 @@ def api_git_pull(request, name): return handle_git_pull(_config, name) +@app.get("/api/projects//git/worktrees") +def api_git_worktree_list(request, name): + from tinyleaf.handlers import handle_git_worktree_list + + return handle_git_worktree_list(_config, name) + + +@app.post("/api/projects//git/worktrees") +def api_git_worktree_add(request, name): + from tinyleaf.handlers import handle_git_worktree_add + + return handle_git_worktree_add(request.json(), _config, name) + + +@app.delete("/api/projects//git/worktrees") +def api_git_worktree_remove(request, name): + from tinyleaf.handlers import handle_git_worktree_remove + + return handle_git_worktree_remove(request.json(), _config, name) + + +@app.post("/api/projects//git/worktrees/switch") +def api_git_worktree_switch(request, name): + from tinyleaf.handlers import handle_git_worktree_switch + + return handle_git_worktree_switch(request.json(), _config, name) + + # ── CORS middleware ──