feat(git): history graph with search, commit details, file history, blame/browse at a revision, compare refs, and multi-commit cherry-pick/revert with preview, pause banner and undo

This commit is contained in:
2026-11-02 12:06:39 +07:00
parent 2b5d7268ed
commit e1f433804c
35 changed files with 6277 additions and 319 deletions
+97 -6
View File
@@ -886,10 +886,67 @@ that file.
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
— `400 ENOBASE` when neither exists — and `400 ENOFILECHANGE` for a `path` with no changes in that range.
#### History: commit details, patch, file history, blame, browse, compare
```http
GET /api/lanes/:id/git/commit?sha=<rev>
GET /api/lanes/:id/git/patch?sha=<rev>
GET /api/lanes/:id/git/file-history?path=<path>[&skip=<n>][&limit=<n>]
GET /api/lanes/:id/git/blame-at?path=<path>[&rev=work|<rev>]
GET /api/lanes/:id/git/tree-at?rev=<rev>[&path=<folder>]
GET /api/lanes/:id/git/compare?a=<rev>&b=<rev>[&mode=three|two][&path=<file>[&from=<old name>]]
GET /api/lanes/:id/git/pick-plan?op=cherry-pick|revert&shas=<sha>,<sha>
GET /api/lanes/:id/git/sequence
```
The History tab's reads (`server/lib/git-history.js`, mounted by
`server/routes/lane-git-history.js`). Read-only and unguarded like every
`/git` read, each resolving its repo exactly like them (the lane row, or
`?session=` / `?source=` / `?repo=`); `{available:false}` with HTTP **200**
for a folder git cannot read. A revision is a sha or a ref name (no leading
`-`, no `..`; `~n`/`^n` suffixes allowed) and is resolved to a commit before
git sees it, else `400 EBADREV`. A path is lane-relative with no `..`
(`400 EBADPATH`). `403 ESECRET` / `EFOREIGN` as for `/git/file`.
- **`/git/commit`** → `{sha, short, subject, message, author, email, date,
committer, committerEmail, committedAt, parents, badges, isHead, branches,
tags, containsTruncated}` — the details pane; `branches`/`tags` contain the
commit (50 each). Its files are `/git/changes?range=commit&sha=`.
- **`/git/patch`** → `{sha, patch, omitted, truncated}` — `git format-patch -1
--stdout --relative` over the commit's files inside the workspace (Copy
patch). A secret file's diff is NEVER in it: it is named in `omitted`.
2 MB cap. A merge commit is `400 EMERGECOMMIT`.
- **`/git/file-history`** → `{path, commits:[{sha, short, subject, author,
date, parents, status, path, from?}], hasMore}` — `git log --follow`: each
commit with the name the file had THERE (`from` = a rename's old name), so
a version's diff is `/git/changes?range=commit&sha=<sha>&path=<its path>`.
- **`/git/blame-at`** → `{path, rev, commits: {<sha>: {short, author, date,
summary, previous?}}, lines: [{sha|null, text}]}` — blame at a commit (or
`work`), with each line's text; `previous` is the commit to blame next
("before this change"). 1 MB cap (`400 ETOOBIG`), `400 ENOFILE`.
- **`/git/tree-at`** → `{rev, path, entries:[{path, name, dir, submodule,
size}], truncated}` — one folder at a commit (Browse files), folders first,
5000 entries; a secret file is left out of the list, and its content stays
refused by `/git/file?rev=`.
- **`/git/compare`** → `{a, b, mode, onlyA, onlyB, commitsTruncated, files,
filesTruncated}` — the commits only in `a` / only in `b`
(`--left-right a...b`, 300 each) and the files that differ: `mode=three`
(default) is `a...b`, what `b` adds since they diverged; `mode=two` is
`a b`, the trees as they stand. With `path` (and a rename's `from`):
`{a, b, mode, file}` with that file's hunks (200 KB cap); a secret file is
`{secret: true, hunks: []}`.
- **`/git/pick-plan`** → `{op, commits, branch, clean, repoRoot, busy}` — what
`cherry-pick`/`revert` with `shas` would run, in the order it would run it
(a pick oldest first, a revert newest first; ancestry, then date), for the
confirmation dialog. `400 EBADSHA` / `EMERGECOMMIT`.
- **`/git/sequence`** → `{active:false}` or `{active:true, operation, current,
remaining, done, startedFrom, conflicts}` — the cherry-pick/revert that is
paused (the banner's "paused at X (2 of 5)"); a merge is not reported here.
#### Read a workspace's commits, a file, or the file tree
```http
GET /api/lanes/:id/git/log?limit=30[&file=<path>][&ref=<branch>]
GET /api/lanes/:id/git/log?limit=30[&skip=<n>][&all=1][&ref=<branch>][&q=<text>][&author=<name>][&path=<path>]
GET /api/lanes/:id/git/blame?path=<path>
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
PUT /api/lanes/:id/git/file {path, content, baseSha}
@@ -907,15 +964,25 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read —
except the file explorer's `/git/tree`, `/git/tree-all` and `/git/ls`, which
read the disk instead for a folder that is not a git repo (below).
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
refs, parents}]}`, newest first in `--date-order` (no parent before its
children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, email,
date, refs, parents, badges}], hasMore, filtered}`, newest first in
`--date-order` (no parent before its children; `parents` = parent shas,
first parent first) — the History tab's commit graph. `badges` are the refs
pointing at the commit, typed for drawing: `{kind: "local"|"remote"|"tag"|"head",
name, current?}` (`current` = the branch HEAD is on, `head` = a detached
HEAD; `refs/remotes/*/HEAD` left out). `limit` is clamped to 500 (default
30) and `skip` pages it; `hasMore` says another page exists. `all=1` reads
every branch, remote branch and tag (`--exclude=refs/stash --all`). Search:
`q=` matches the message case-insensitively and LITERALLY (never a regex),
and a hex `q` also puts the commit with that SHA prefix first; `author=`
matches name or e-mail, literally; `path=` (alias `file=`) narrows it to the
commits touching that path — relative, no `..` segment, else
`400 EBADPATH` — with each `parents` REWRITTEN to the file's own previous
commit (`--parents`), so the file's graph is one joined-up line. `ref=` reads another branch's history instead of HEAD's
(where the panel's Cherry-pick finds its commits): a local or
remote-tracking branch that exists, else `400 ENOBRANCH`. Reading one commit's diff is
`/git/changes?range=commit&sha=`.
remote-tracking branch that exists, else `400 ENOBRANCH`. `q`/`author` over
200 characters are `400 EBADQUERY`; a walk is killed after 20 s. Reading
one commit's diff is `/git/changes?range=commit&sha=`.
- **`/git/blame`** → `{available, path, commits: {<sha>: {short, author,
date, summary}}, lines: [<sha>|null]}` — `git blame --porcelain` of the
working-tree file (Annotate); `null` = a line not committed yet. Same
@@ -1198,6 +1265,11 @@ cannot collide on `index.lock`.
| `merge-abort` | `git merge --abort` (or `cherry-pick --abort` / `revert --abort`, whichever is open) | `{"confirm": true}` |
| `cherry-pick` | `git cherry-pick <sha>` — a non-merge commit, onto a clean repo-root workspace | `{"sha": "9b3e74a"}` |
| `revert` | `git revert --no-edit <sha>` — same guards as `cherry-pick` | `{"sha": "9b3e74a"}` |
| `cherry-pick` / `revert` with `shas` | ONE git sequence of 1–50 non-merge commits, in `/git/pick-plan`'s order (`server/lib/git-pick.js`) | `{"shas": ["9b3e74a", "c1d2e3f"]}` |
| `pick-continue` | `git cherry-pick --continue` / `git revert --continue` once nothing is unmerged | `{}` |
| `pick-skip` | `--skip` the commit the pick/revert stopped at (drops its resolutions) | `{"confirm": true}` |
| `pick-abort` | `--abort`: back to where the pick/revert started | `{"confirm": true}` |
| `undo-pick` | `git reset --soft <from>` after a pick/revert — its changes stay staged | `{"from": "<sha>", "to": "<sha>", "confirm": true}` |
| `undo-commit` | `git reset --soft HEAD~1` — only an UNPUSHED, non-merge, non-root HEAD that is still `sha`; its changes stay staged | `{"confirm": true, "sha": "<HEAD the user saw>"}` |
| `amend` | `git commit --amend -m <message>`, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands, or with `paths` exactly those files (`--only`; an empty list rewords and needs a message) | `{"message": "...", "sha": "<HEAD>", "paths": ["..."]}` |
| `shelve` | `git stash push --include-untracked -m "agenthub-shelf: <branch> · <ISO time>[ · <note>]" [-- <paths>]` — everything, or exactly the ticked changed `paths` | `{"note": "...", "paths": ["..."]}` |
@@ -1327,6 +1399,25 @@ cannot collide on `index.lock`.
failing signer) is undone the same way and answers git's own error. A
rebase stopped in a terminal makes every history action `409 EMERGING`;
`merge-commit`/`merge-abort` then continue/abort that rebase.
- **Several commits at once** (`shas` instead of `sha`, `server/lib/git-pick.js`)
run as ONE git sequence — `git cherry-pick A B C`, `git revert --no-edit C B A`
— in the order `GET /git/pick-plan` shows (a pick oldest first, a revert
newest first), under the same guards (1–50 commits, none a merge, a clean
repo-root tree, nothing already open — `409 EMERGING`). The answer is
`{from, to, commits, conflicts, paused, applied}`: `paused: true` with
`conflicts[]` stopped at a conflict, with `empty: true` at a commit whose
changes are already here. A paused one shows on `GET /git/sequence` and is
finished by `pick-continue` (`409 ECONFLICTS` while anything is unmerged,
`409 EEMPTY` when the resolution changed nothing — skip it then),
`pick-skip` or `pick-abort` (both `confirm: true`, `400 ECONFIRM`; abort
puts the branch back at `from`), `409 ENOMERGE` when nothing is open.
`undo-pick {from, to, confirm: true}` takes a finished pick/revert back with
`reset --soft <from>` (the changes stay staged): only while HEAD is still
`to` (`409 ESTALE`), `from` is its ancestor 1–50 commits back with no merge
between (`400 EBADSHA` / `EMERGECOMMIT`), and none of those commits is on a
remote-tracking branch (`409 EPUSHED`). The single-`sha` body is unchanged.
While a rebase is open every one of these is `409 EMERGING`, and
`GET /git/sequence` does not report a rebase as a paused pick.
- **Undo commit and amend** rewrite the last commit, so both are
`409 EPUSHED` once any remote-tracking branch contains HEAD, `409 ESTALE`
when HEAD is no longer the `sha` the request names (`400 EBADSHA` without