feat(git): Branches & tags panel — rename, branch from any ref, upstream, tags

GET /git/refs lists local branches (upstream, ahead/behind, last commit,
merged, held by a worktree, protected), remote branches grouped by remote
and tags (target, annotated message/tagger/date). New writes in
server/lib/git-refs.js, dispatched from git-ops.js: branch-rename,
branch-upstream (set/unset), tag-create (light/annotated), tag-delete
(confirm), tag-push (one tag to origin, no --tags, no force), and
branch-create with a looked-up from + switch on a clean tree. Every ref is
looked up, never passed through; repo-wide writes are repo-root only
(ESUBDIR) and refused during any operation (EMERGING).

Client: git/BranchManager + TagManager dialog opened from the git bar's
tags icon, en+vi (gitRefs namespace), keyboard navigable.

# Conflicts:
#	CLAUDE.md
#	client/src/components/lanes/LaneGitBar.tsx
#	docs/API.md
#	server/__tests__/lanes-api.test.js
#	server/routes/lanes.js
This commit is contained in:
2026-11-02 11:52:40 +07:00
parent f6eb39eb73
commit 2cc492bb88
20 changed files with 2580 additions and 9 deletions
+84 -4
View File
@@ -1241,6 +1241,70 @@ export interface FsTrashItem {
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
export type FsConflict = "fail" | "skip" | "keep" | "replace";
/** A local branch as the Branches panel lists it (`GET …/git/refs`). */
export interface GitLocalBranch {
name: string;
sha: string;
current: boolean;
upstream: string | null;
/** Null without an upstream, or when it is `gone`. */
ahead: number | null;
behind: number | null;
gone: boolean;
subject: string;
date: string | null;
/** Merged into HEAD — what `git branch -d` checks. */
merged: boolean;
/** Another worktree holding it, which `branch -d` refuses. */
worktree: string | null;
protected: boolean;
}
/** A fetched remote-tracking branch (`origin/x`). */
export interface GitRemoteBranch {
name: string;
branch: string;
sha: string;
subject: string;
date: string | null;
}
/** A tag: its target commit and, when annotated, its message and tagger. */
export interface GitTag {
name: string;
sha: string | null;
annotated: boolean;
message: string | null;
tagger: string | null;
date: string | null;
}
export interface GitRefs {
available: boolean;
current: string | null;
head: string | null;
local: GitLocalBranch[];
remotes: { remote: string; branches: GitRemoteBranch[] }[];
tags: GitTag[];
truncated: boolean;
}
export type GitRefAction =
| "branch-create"
| "branch-rename"
| "branch-upstream"
| "branch-delete"
| "merge"
| "tag-create"
| "tag-delete"
| "tag-push";
export interface GitRefBody {
branch?: string;
name?: string;
from?: string;
switch?: boolean;
upstream?: string;
tag?: string;
message?: string;
confirm?: boolean;
}
/** The target for `laneId`: an extra folder's or an inner repo, else `session`'s worktree, else the workspace. */
export function gitTarget(
laneId: number,
@@ -4436,6 +4500,19 @@ export const api = {
current: string | null;
worktrees: LaneWorktree[];
}>(gitUrl(id, "/branches")),
/** GET /api/lanes/:id/git/refs — every branch and tag, for the Branches panel. */
gitRefs: (id: GitTarget) => request<GitRefs>(gitUrl(id, "/refs")),
/**
* POST /api/lanes/:id/git/:action — a branch & tag write (server/lib/git-refs.js):
* `branch-create {branch, from?, switch?}`, `branch-rename {branch, name}`,
* `branch-upstream {branch, upstream?}` (none = unset), `tag-create {tag, message?, from?}`,
* `tag-delete {tag, confirm: true}`, `tag-push {tag}`, plus `merge`/`branch-delete {branch}`.
*/
gitRefAction: (id: GitTarget, action: GitRefAction, body: GitRefBody) =>
request<{ action: string; ok: true; output: string; branch?: string; tag?: string }>(
gitUrl(id, `/${action}`),
{ method: "POST", body: JSON.stringify(body) }
),
/**
* GET /api/lanes/:id/git/shelves — the stash entries AgentHub made
* (shelves), newest first. Other stashes are never listed.
@@ -4620,10 +4697,13 @@ export const api = {
background?: boolean;
}
) =>
request<(LaneSourcesView & { path: string }) | { job: CloneJob }>(`/workspaces/${id}/sources/clone`, {
method: "POST",
body: JSON.stringify(body),
}),
request<(LaneSourcesView & { path: string }) | { job: CloneJob }>(
`/workspaces/${id}/sources/clone`,
{
method: "POST",
body: JSON.stringify(body),
}
),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/workspaces/${id}/sources/folder`, {