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:
+45
-1
@@ -1016,6 +1016,25 @@ comes from the workspace row, never from the query (that is what `GET
|
||||
not exist yet; in server mode with `AGENTHUB_USERS_ROOT` set, a developer may name
|
||||
only a repo inside their own folder, else `403 EFOLDER`).
|
||||
|
||||
#### Read every branch and tag (the Branches panel)
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/refs
|
||||
```
|
||||
|
||||
`{"available": true, "current": "main", "head": "<sha>", "local": [...], "remotes": [{"remote": "origin", "branches": [...]}], "tags": [...], "truncated": false}`
|
||||
— what the git bar's **Branches & tags** panel (`client/src/components/lanes/git/BranchManager.tsx`)
|
||||
lists, read by `server/lib/git-refs.js`. Each `local` entry carries `name`,
|
||||
`sha`, `current`, `upstream` (or null), `ahead`/`behind` against it (null
|
||||
without one; `gone: true` when the upstream ref is gone), the last commit's
|
||||
`subject` and `date`, `merged` (into HEAD — what `git branch -d` checks),
|
||||
`worktree` (another worktree holding it) and `protected`. `remotes` groups the
|
||||
fetched remote-tracking branches by remote (symbolic `HEAD`s left out). Each
|
||||
tag carries `name`, its target commit `sha`, `annotated`, and for an
|
||||
annotated tag its `message`, `tagger` and `date`. At most 1000 of each kind
|
||||
(`truncated`). Same target rules as every git read (`?session=`, `?source=`,
|
||||
`?repo=`); a folder that is not a repo answers `available: false` with 200.
|
||||
|
||||
#### A session's own worktree (`?session=`)
|
||||
|
||||
Every `/api/lanes/:id/git…` route above and below takes `?session=<id>`: the
|
||||
@@ -1127,8 +1146,13 @@ cannot collide on `index.lock`.
|
||||
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
|
||||
| `switch` | `git switch [-c] <branch>`; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones. A dirty tree needs `changes`: `carry` (a plain `git switch` that brings the changes along) or `shelve` (shelve them, then switch) | `{"branch": "...", "create": false, "changes": "carry\|shelve", "note": "..."}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — or, with `switch: true`, `git switch -c <branch> [<base>]`, the uncommitted changes coming along | `{"branch": "...", "base": "...", "switch": true}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — or, with `switch: true`, `git switch -c <branch> [<base>]`, the uncommitted changes coming along. With `from`, the start point is LOOKED UP (a local or remote branch — a remote one becomes the upstream —, a tag, or a hex sha verified as a commit) and `switch: true` then runs `git switch <branch>` on a clean tree only | `{"branch": "...", "base": "...", "switch": true}` / `{"branch": "...", "from": "v1.2.0", "switch": true}` |
|
||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
||||
| `branch-rename` | `git branch -m <branch> <name>` — the current branch too | `{"branch": "...", "name": "..."}` |
|
||||
| `branch-upstream` | `git branch --set-upstream-to=refs/remotes/<upstream> <branch>`, or `--unset-upstream` with no `upstream` | `{"branch": "...", "upstream": "origin/x"}` / `{"branch": "..."}` |
|
||||
| `tag-create` | `git tag <tag> <sha>`, or `git tag -a -m <message> <tag> <sha>` when a message is given — on HEAD or on a looked-up `from` | `{"tag": "v1.2.0", "message": "...", "from": "..."}` |
|
||||
| `tag-delete` | `git tag -d <tag>` — this machine only | `{"tag": "...", "confirm": true}` |
|
||||
| `tag-push` | `git push <origin\|the only remote> refs/tags/<tag>:refs/tags/<tag>` — ONE tag, never `--tags`, never forced | `{"tag": "..."}` |
|
||||
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
|
||||
| `merge` | `git merge --no-edit -m "Merge branch '<branch>'[ into <current>]" <ref>` — a local or remote-tracking branch that exists; the subject is git's own, never the full refname | `{"branch": "origin/main"}` |
|
||||
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
|
||||
@@ -1263,6 +1287,26 @@ cannot collide on `index.lock`.
|
||||
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
|
||||
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
|
||||
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
|
||||
- **Branch & tag tools** (`server/lib/git-refs.js`, the Branches panel): every
|
||||
ref the body names is LOOKED UP — under `refs/heads`, `refs/remotes` or
|
||||
`refs/tags`, or a hex sha `rev-parse --verify <x>^{commit}` confirms — and git
|
||||
is handed the full refname or sha it found, never the request's text
|
||||
(`400 EBADREF`; `400 EAMBIGUOUS` when a branch and a tag share the name —
|
||||
spell out `refs/tags/x`). New names go through `git check-ref-format`
|
||||
(`400 EBADBRANCH` / `400 EBADTAG`; a tag may not start with `-`).
|
||||
`branch-rename`, `branch-upstream` and the three tag actions are repo-wide,
|
||||
so they run only from a repo-root workspace (`400 ESUBDIR`) and never while a
|
||||
merge, cherry-pick, revert or rebase is open (`409 EMERGING`; so is
|
||||
`branch-create` with `switch: true`). `branch-rename` refuses the protected
|
||||
names (`400 EPROTECTED`) and an existing name (`400 EBRANCHEXISTS`);
|
||||
`branch-upstream` takes only a fetched remote branch (`400 ENOBRANCH`) and
|
||||
unsetting one that has none is `400 ENOUPSTREAM`; `tag-create` refuses an
|
||||
existing tag (`400 ETAGEXISTS`) and needs a commit (`400 ENOCOMMITS`);
|
||||
`tag-delete` is irreversible and needs `confirm: true` (`400 ECONFIRM`);
|
||||
`tag-delete`/`tag-push` of a missing tag is `400 ENOTAG`. `tag-push` goes to
|
||||
`origin`, else the only remote (`400 ENOREMOTE`), with the same `EGITAUTH`
|
||||
login flow as `push`; a remote tag already at another commit is a `409 EGIT`
|
||||
in git's words. Deleting a REMOTE tag is not offered.
|
||||
- `publish` is the one write whose **destination comes from the request**, so
|
||||
it only ever gives a remote to a repo that has none: `400 ENOCOMMITS` before
|
||||
the first commit, `400 EREMOTEEXISTS` once any remote exists (it never
|
||||
|
||||
Reference in New Issue
Block a user