Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions src/tinyleaf/git_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()}
43 changes: 43 additions & 0 deletions src/tinyleaf/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>--<branch>
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}
28 changes: 28 additions & 0 deletions src/tinyleaf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,34 @@ def api_git_pull(request, name):
return handle_git_pull(_config, name)


@app.get("/api/projects/<name>/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/<name>/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/<name>/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/<name>/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 ──


Expand Down
Loading