Skip to content

feat: add branch switch/create/delete and stash operations - #69

Merged
Oaklight merged 1 commit into
masterfrom
feature/branch-management-ops
Sep 13, 2026
Merged

Oaklight merged 1 commit into
masterfrom
feature/branch-management-ops

Conversation

@Oaklight

Copy link
Copy Markdown
Owner

Summary

  • Add switch_branch(), create_branch(), delete_branch(), stash(), and stash_pop() to git_ops.py with proper error handling (non-git repos, dirty working tree, invalid branch names, deleting current branch)
  • Add corresponding handler functions in handlers.py with input validation
  • Add API routes in server.py: POST /git/branches/switch, POST /git/branches, DELETE /git/branches/<name>, POST /git/stash, POST /git/stash/pop

Part of #59. Closes #61. Closes #62.

Test plan

  • All new git_ops functions tested against a real temp git repo: create/switch/delete branches, stash/pop, error cases (non-git dir, invalid branch name, delete current branch, switch nonexistent, dirty working tree, empty stash pop)
  • Handler and server imports verified
  • ruff check and ruff format pass
  • Pre-commit hooks (ruff, ruff format, ty check) all pass

@Oaklight
Oaklight merged commit 9b0e793 into master Sep 13, 2026
2 checks passed
@Oaklight
Oaklight deleted the feature/branch-management-ops branch September 13, 2026 09:18

@milo-oaklight milo-oaklight Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-Merge Review — PR #69

Overall this is a clean, well-structured addition. The check-ref-format validation in create_branch, the current-branch guard in delete_branch, and the consistent use of list-based subprocess args are all good. A few things to address in follow-ups:


🐛 Bug: DELETE route won't match branch names with /

server.py — api_git_delete_branch

The route is:

@app.delete("/api/projects/<name>/git/branches/<branch_name>")

A plain <branch_name> param won't match names like feature/foo — the router will interpret foo as a separate path segment and 404. The vendored httpserver supports <path:branch_name> for slash-inclusive captures. Should be:

@app.delete("/api/projects/<name>/git/branches/<path:branch_name>")

This affects any conventional topic/name branching workflow.


🔒 Defensive: missing -- separator before user-supplied refs

git_ops.py — switch_branch, create_branch

switch_branch passes the user-supplied branch directly:

_run_git(project_dir, "switch", branch)

If branch starts with - (e.g. --help, --orphan), git interprets it as a flag, not a ref. create_branch validates the name via check-ref-format (which rejects --prefixed names), but start_point is appended without any validation or -- separator:

args = ["switch", "-c", name]
if start_point:
    args.append(start_point)

Suggested fix — add -- before positional args:

# switch_branch
_run_git(project_dir, "switch", "--", branch)

# create_branch
args = ["switch", "-c", name]
if start_point:
    args.extend(["--", start_point])
else:
    args.append("--")  # still good practice

Not exploitable for injection (subprocess is list-based), but can cause unexpected behavior.


🧩 Feature gap: stash operations are minimal

Only stash (push) and stash pop are exposed. For a useful stash workflow, consider:

  • stash list — users need to see what's stashed before popping, especially across worktrees
  • stash apply (vs pop) — non-destructive apply, important when conflicts arise
  • stash drop — clean up stash entries after manual apply

Also worth noting: git stash pop with merge conflicts will apply the changes but not drop the stash entry, and returns non-zero. The current code reports success: False in that case, but the working tree is already modified. Callers may see "failed" and not realize changes were partially applied. A follow-up could detect this state (conflicts present + stash entry still exists) and return a more informative response.

No stash message support (git stash push -m "wip: feature") — would be a nice addition.


🌳 Worktree interaction (re: PR #68)

Two things to be aware of when both PRs are in play:

  1. delete_branch only checks the current worktree. The guard uses git branch --show-current for the project dir, but a branch could be checked out in a different worktree. git branch -d will still refuse (git itself enforces this), but the error message will be less clear than the custom "Cannot delete the current branch" message. Could enhance the check with git worktree list --porcelain to cover all worktrees.

  2. Stash scope in worktrees. Since git 2.35, each worktree has its own stash stack. On older git versions, stashes are shared. If tinyleaf needs to support older git, this could lead to surprising cross-worktree stash behavior. Might be worth documenting the minimum git version assumption.


ℹ️ Pre-existing: blocking subprocess in async context

Not introduced by this PR, but now there are more blocking subprocess.run() calls in the async server's request path. The vendored httpserver is asyncio-based, so each _run_git call blocks the event loop for up to 30 seconds. Might be worth a tracked issue to migrate _run_git to asyncio.create_subprocess_exec — or at minimum loop.run_in_executor — before the git operation surface area grows further.


✅ Things done well

  • check-ref-format --branch validation in create_branch — correct and sufficient
  • Current-branch deletion guard in delete_branch
  • _json_body gracefully handles bodyless DELETE requests
  • List-based subprocess throughout — no shell injection surface
  • Clean handler/ops/route separation

Solid PR. The slash-in-branch-name routing bug is the one I'd fix first.

— Milo 👻

@elena-oaklight elena-oaklight Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-Merge Review — Branch & Stash Operations

Clean, well-structured PR. The subprocess safety fundamentals are solid (_run_git uses list-form subprocess.run, no shell=True), and create_branch properly validates via git check-ref-format --branch. A few things to address in follow-ups:


🔴 Bug: Branch names with slashes break DELETE route

DELETE /api/projects/<name>/git/branches/<branch_name> uses the default <branch_name> path parameter, which maps to regex ([^/]+) — it won't capture slashes.

Branches like feature/login or bugfix/typo (extremely common Git conventions) would either 404 or only capture the first segment.

Options:

  1. Use <path:branch_name> to capture the full path
  2. Move the branch name to the request body or a query parameter
  3. URL-encode the branch name client-side and decode in the handler

Option 1 is simplest but needs care not to collide with other routes.


🟡 Missing -- separator — argument injection via flag-like names

switch_branch and delete_branch pass user-supplied names directly as positional arguments without a -- separator:

# switch_branch
_run_git(project_dir, "switch", branch)

# delete_branch  
_run_git(project_dir, "branch", flag, name)

While create_branch is protected by check-ref-format --branch (which rejects names starting with -), the other two functions are not. A name like --orphan passed to git switch --orphan would be interpreted as a flag, not a branch name.

Fix: Add -- before the branch name argument:

_run_git(project_dir, "switch", "--", branch)
_run_git(project_dir, "branch", flag, "--", name)

This is a standard defense against argument injection and costs nothing.


🟡 stash on clean tree silently "succeeds"

git stash on a clean working tree exits with rc=0 and prints "No local changes to save" — so stash() returns {"success": True, "message": "No local changes to save"}.

If a client naively does stash → switch → pop, the "successful" stash that didn't actually create an entry means stash_pop would either pop an older, unrelated stash entry or fail on an empty stash. Both outcomes are surprising.

Suggestion: Detect the "nothing to stash" case and return success: False or a distinct status field (e.g. "stashed": False).


🟢 Minor: force flag on DELETE body

The delete_branch handler reads force from the request body, but many HTTP clients don't send bodies with DELETE requests. The _json_body helper handles this gracefully (returns {}), so it works — but a query parameter (?force=true) would be more conventional for DELETE.


✅ What's done well

  • Current-branch deletion guard — correctly refuses via git branch --show-current check, handles detached HEAD gracefully (empty string never matches)
  • Force vs safe delete — properly distinguishes -d vs -D
  • Dirty working tree on switch — correctly delegates to git switch's built-in refusal, error message propagated
  • No remote branch deletion — only local git branch operations, appropriate scope
  • Consistent error return shape — all functions return {"success": bool, "message": str}, matching existing patterns
  • Input validation — handlers properly check for empty branch names with abort(400)

Overall this is solid work. The slash-in-branch-name bug is the main thing to fix — it affects real-world usage patterns.

@clementine-oaklight clementine-oaklight Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #69 — feat: add branch switch/create/delete and stash operations → LGTM ✅

Backend 实现干净:create_branch 用 git check-ref-format --branch 校验输入,delete_branch 拒绝删当前分支,stash / stash_pop 简洁。没有 blocking issue。

🐛 Bug(中等): branch name 含斜杠时 DELETE route 失效

@app.delete("/api/projects/<name>/git/branches/<branch_name>")

<branch_name> 是单路径段匹配。常见分支名如 feature/login 会被路由解析为两段 (feature + login),404。

前端 deleteBranch 用了 enc(name)(即 encodeURIComponent),会把 / 编码成 %2F。但很多 WSGI 框架在路由匹配前会先 decode %2F,导致同样的问题。

可行修法:和 worktree 的 DELETE 一样把 branch name 放 request body 里,不走 URL path。

Non-blocking

  • switch_branch 不检查 dirty state——设计上由前端(#70)负责,但如果有人直接调 API 可能丢数据。backend 加个 warning 级的 dirty 检查(不 blocking switch,但返回 had_changes: true)会更 defensive。
  • stash() 在没有 changes 时 git stash 返回 No local changes to save(rc=1 on older git, rc=0 on newer),行为可能因 git 版本不一致。

@Oaklight

Copy link
Copy Markdown
Owner Author

Addressing review feedback

Thanks @milo-oaklight for the post-merge review. Fixing all items:

  • Bug: DELETE route won't match branch names with /: Will change <branch_name> to <path:branch_name> ✅
  • Missing -- separator in switch_branch/create_branch: Will add -- before user-supplied refs ✅
  • Stash operations minimal: Good suggestions for future enhancement — will file as a separate issue
  • delete_branch worktree interaction: Noted — git itself handles the "branch checked out in another worktree" error, but worth improving the error message

All bug fixes in the follow-up PR #72.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API: branch management endpoints (switch/create/delete) git_ops: branch switch, create, and delete operations

1 participant