Compare commits

...

12 Commits

Author SHA1 Message Date
nntrivi2001 1ec9f2e592 chore: v2.8.55 — chat + menu no longer covered by the controls under the prompt box 2026-11-02 13:30:33 +07:00
nntrivi2001 a401a318c5 fix(chat): + menu paints above the controls row under the prompt box
Opened downward, the panel had no z-index, so the AI Proxy / Permission /
Local selects (positioned, later in the DOM) drew over its first rows.
2026-11-02 13:29:08 +07:00
nntrivi2001 5caad0df2d chore: v2.8.54 — chat Stop shows while Claude thinks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:29:05 +07:00
nntrivi2001 01e66ba122 fix(chat): Stop shows while Claude thinks, not only while its footer says "esc to interrupt"
The chat's live line said "Thinking… 21s" while the box offered Send, so the
turn could not be stopped. Stop shows only when a hook marked main "working"
or the pane's busy line matched, and Claude Code 2.1.3xx writes
"esc to interrupt" only in its footer, which a narrow pane, a tip or a notice
replaces. Claude's busy line now also matches its spinner (verb… (41s · …)),
which the line a finished turn leaves ("Cooked for 1m 2s") does not; and the
chat re-reads "working" on transcript_changed, so a hook that reached another
dashboard on the same database no longer leaves Stop hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:29:05 +07:00
nntrivi2001 923c1635b4 chore: v2.8.53 — chat + menu opens downward when there is no room above
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:25:38 +07:00
nntrivi2001 c37bc5205b fix(chat): + menu opens downward when the prompt box sits too near the top
On the workspace home the box is high on the screen, so the menu and its
submenus (Project files) opened upward and were clipped off the top.
Measure the room above/below (viewport narrowed by clipping ancestors) at
open and drop the menu below the button, submenus top-aligned, when it fits better.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:25:05 +07:00
nntrivi2001 841263bce1 chore: v2.8.52 — git: switch with changes + shelves, branches & tags, history graph + multi cherry-pick, merge editor, rebase/reset/force-with-lease 2026-11-02 12:28:58 +07:00
nntrivi2001 e1f433804c 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 2026-11-02 12:22:46 +07:00
nntrivi2001 2b5d7268ed feat(git): 3-way merge editor, reflog, rebase/interactive rebase, reset with undo, force-with-lease push, remote branches and remotes
Server: git-rewrite.js (rebase + continue/skip/abort, rebase-interactive with
no editor, reset soft/mixed/keep/hard, undo-rewrite, force-push with lease,
remote-branch-delete, remote add/rename/remove) and git-conflicts.js
(conflict stages read + conflict-save, reflog), dispatched from runGitAction;
operationInProgress now reports a rebase and only ALLOWED_WHILE_REBASING
writes run during one. New reads: /git/conflict, /git/reflog, /git/remotes,
/git/rewrite-plan. Client: History tools menu, MergeEditor, ReflogView,
RebaseDialog, InteractiveRebase, ResetDialog, ForcePushDialog, RemotesDialog,
rebase banner Skip. CLAUDE.md git-writes rule rewritten for the new actions.
2026-11-02 12:12:45 +07:00
nntrivi2001 2cc492bb88 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
2026-11-02 12:00:55 +07:00
nntrivi2001 f6eb39eb73 feat(git): switch with uncommitted changes (carry / shelve), Create & switch, and shelves with Restore/Delete
- switch takes changes: carry (plain git switch, 409 ECARRYCONFLICT with files) or shelve (shelf first, re-applied if the switch fails)
- branch-create switch: true runs git switch -c, the changes come along
- shelve / unshelve / shelf-drop + GET /git/shelves: only agenthub-shelf: stash entries, addressed by sha, repo root only, never mid-operation; a clean restore drops the shelf, a conflicted one keeps it
- 409 EAGENTRUNNING until confirm: true while the workspace's agent is working
- git bar: Bring my changes / Shelve and switch choice, Create & switch, restore offer after a switch; Changes panel: Shelve (n) and the Shelves list
- fix: after a switch the Changes panel showed the old branch — the post-write re-read joined an in-flight GET started before the write (request() dedupe); writes now close in-flight GETs, and every git write fires agenthub:git-changed so every view of the workspace re-reads
2026-11-02 11:44:35 +07:00
nntrivi2001 2b26fe1efa fix(jobs): a failed clone's chip is readable and clickable
A failed or stopped clone also raised a toast on the same corner, a layer
above the dock, so it covered the chip's x / Retry / Stop and repeated the
same long message; the chip now says it alone, and every toast stacks above
the dock (--jobs-dock-h). A URL with no space to break at (user@host:/PATH)
wraps inside the chip and inside a toast instead of running off the screen.
Open Settings -> Git is a router link; a plain <a href> reloaded the app.
2026-11-02 11:26:48 +07:00
90 changed files with 16012 additions and 448 deletions
+5 -1
View File
@@ -388,7 +388,11 @@ graph TD
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list; in a folder that is not a git repo they, and `listTreeAll`, read the disk instead through `walkFolder` — realpath-confined, symlinks never followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
| `lib/lane-file-transfer.js` | (#78) The Files tab's download and upload. `openDownload` streams one working file of any type (up to 512 MB) behind the file read's guards (`lanePath`, `resolveInLane` realpath confinement, `isSecretOnDisk`, `openSafe` for a member's file); the route serves it as an `application/octet-stream` attachment under a `sandbox` CSP with `nosniff`. `saveUpload` only CREATES (`EEXISTS` for a taken name, a symlink included), into an existing folder, never under `.git`, never a secret name, at most 25 MB — under S3 as the folder's member through `write-as`, else with an exclusive `wx` create. Works in a folder that is not a git repo |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. History rewrites and remote writes are dispatched to `lib/git-rewrite.js` (below); a stopped rebase lets through only `ALLOWED_WHILE_REBASING`. No stash beyond shelves. (`undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged.) `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/git-rewrite.js` | History rewrites and remote writes, each allowed because its recovery is a button too: `rebase` (+ `rebase-continue`/`-skip`/`-abort`; `merge-commit`/`merge-abort` map onto an open rebase), `rebase-interactive` (todo + messages as 0600 files in the repo's git dir, `cp` as sequence editor, `true` as editor, squash = `fixup` + `exec git commit --amend -F <file>`), `reset` soft/mixed/keep/hard (hard: clean tree + branch name typed back), `undo-rewrite` (`reset --keep <before>` while HEAD is still `after`), `force-push` (`--force-with-lease` on the sha the plan showed, HEAD only, protected names refused unless `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`), `remote-branch-delete` (never the default or a protected branch), `remote-add`/`-rename`/`-remove` (URL through `validateRemoteUrl`). Every one: repo root (`ESUBDIR`), no open operation (`EMERGING`), the planned HEAD (`ESTALE`), pushed commits `EPUSHED` unless `force: true`. Plus the reads `rewritePlan` and `listRemotes` (passwords stripped) |
| `lib/git-conflicts.js` | The 3-way merge editor's read (`readConflict`: stages :1/:2/:3 by blob id for a path `ls-files -u` lists, the working file and its sha) and save (`conflict-save`: `lane-file-write.js` then `git add`, `EMARKERS` before writing), and the read-only reflog (`readReflog`) |
| `lib/git-history.js` | The History tab's READS (via `routes/lane-git-history.js`, plus `/git/log`): the commit graph paged with `skip`, `all=1` and literal search (`q`, a SHA prefix, `author`, `path`) and typed ref badges; one commit's details; its `format-patch` (a secret file left out); a file's `--follow` history; blame at a revision; a tree at a revision; two refs compared (left/right commits, `A...B` or `A B` files, one file's hunks); the plan of a multi-commit pick/revert; a paused pick's sequencer state. Every revision is `SAFE_REV`-checked and resolved to a sha first, every path `lanePath`/`isSecretOnDisk`, walks time out at 20 s, outputs are capped |
| `lib/git-pick.js` | The multi-commit `cherry-pick`/`revert` (`shas`, one git sequence in `pickPlan`'s order), the paused one's `pick-continue`/`pick-skip`/`pick-abort` (the last two `confirm:true`) and `undo-pick` (`reset --soft <from>` only while HEAD is the `to` the pick made and none of it is on a remote). Dispatched from `git-ops.js`'s `runGitAction`; same clean-tree, repo-root and `EMERGING` guards |
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
+2 -2
View File
File diff suppressed because one or more lines are too long
+28 -7
View File
@@ -13,9 +13,9 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { ChevronDown, FolderGit2, Square, Upload, X } from "lucide-react";
import type { CloneJob } from "../lib/api";
import {
@@ -156,15 +156,16 @@ function CloneJobChip({ job }: { job: CloneJob }) {
<>
<p
role={job.state === "cancelled" ? undefined : "alert"}
className={`text-[11px] ${
className={`text-[11px] [overflow-wrap:anywhere] ${
job.state === "cancelled" ? "text-fg-secondary" : "text-status-danger"
}`}
>
{cloneJobError(job)}{" "}
{code === "ESSHAUTH" && (
<a href="/settings/git#git-identity" className="underline">
// A router link: a plain <a href> reloaded the whole app.
<Link to="/settings/git#git-identity" className="underline">
{t("clone.sshSettings")}
</a>
</Link>
)}
</p>
{code === "ECREDENTIALS" && job.state === "failed" ? (
@@ -226,7 +227,7 @@ function CloneJobChip({ job }: { job: CloneJob }) {
)
)}
{error && (
<p role="alert" className="text-[11px] text-status-danger">
<p role="alert" className="text-[11px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
@@ -244,7 +245,7 @@ function UploadChip({ job }: { job: UploadJob }) {
<div className={chipClass} data-testid="upload-job" data-state="interrupted">
<div className="flex items-start gap-2">
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary">
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
{t("uploads.interrupted", {
total: job.total,
folder: job.folder,
@@ -305,15 +306,35 @@ export function BackgroundJobsDock() {
const jobs = useCloneJobs();
const uploads = useUploadJobs();
const [collapsed, setCollapsed] = useState(false);
const dockRef = useRef<HTMLDivElement>(null);
useEffect(() => ensureCloneJobs(), []);
const count = jobs.length + uploads.length;
// Toasts stack ABOVE the dock (Toaster reads --jobs-dock-h): they used to
// sit on the same corner and cover a chip's × / Retry / Stop.
useEffect(() => {
const root = document.documentElement;
const el = dockRef.current;
if (!count || !el) {
root.style.removeProperty("--jobs-dock-h");
return;
}
const set = () => root.style.setProperty("--jobs-dock-h", `${el.offsetHeight + 8}px`);
set();
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
ro?.observe(el);
return () => {
ro?.disconnect();
root.style.removeProperty("--jobs-dock-h");
};
}, [count, collapsed]);
if (!count) return null;
const running =
jobs.filter(isActiveJob).length + uploads.filter((u) => u.state !== "interrupted").length;
return (
<div
ref={dockRef}
role="region"
aria-label={t("jobs.dock")}
data-testid="jobs-dock"
+6 -2
View File
@@ -25,7 +25,9 @@ function Item({ item }: { item: ToastItem }) {
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
>
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
<span className="text-xs leading-relaxed flex-1 min-w-0 break-words [overflow-wrap:anywhere]">
{item.message}
</span>
<button
type="button"
onClick={() => dismissToast(item.id)}
@@ -44,8 +46,10 @@ export function Toaster() {
useEffect(() => subscribeToasts(setItems), []);
const errors = items.filter((i) => i.kind === "error");
const rest = items.filter((i) => i.kind !== "error");
// Above the background-jobs dock (BackgroundJobsDock sets --jobs-dock-h
// to its height), so a toast never covers a chip's buttons.
return (
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
<div className="pointer-events-none fixed bottom-[calc(2.5rem+var(--jobs-dock-h,0px))] right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
<div role="status" aria-live="polite" className="flex flex-col gap-2">
{rest.map((i) => (
<Item key={i.id} item={i} />
@@ -94,7 +94,10 @@ describe("cloneJobs store", () => {
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
window.addEventListener(CLONE_JOB_EVENT, listen);
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
await startClone({ kind: "workspace", body: { url: base.url, parent: "/home/an" } }, { onDone });
await startClone(
{ kind: "workspace", body: { url: base.url, parent: "/home/an" } },
{ onDone }
);
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
expect(toast.success).not.toHaveBeenCalled();
@@ -205,7 +208,8 @@ describe("BackgroundJobsDock", () => {
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
const chip = await screen.findByText("You stopped this clone.");
expect(chip).toBeInTheDocument();
expect(toast.info).toHaveBeenCalledWith("Stopped cloning repo.");
// The chip says it; no toast repeats it on top of the chip.
expect(toast.info).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
@@ -287,4 +291,27 @@ describe("BackgroundJobsDock", () => {
await user.click(screen.getByTestId("jobs-dock-pill"));
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
});
it("wraps a long SSH error, links to Settings in-app and lifts toasts above itself", async () => {
const long = "mdpartners@mdpartners.git.backlog.jp:/PETITGIFT_CONSOLE4/console4-specs.git";
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "failed",
url: long,
error: { code: "ESSHAUTH", message: "the SSH login was refused" },
}),
],
});
renderDock();
const alert = await screen.findByRole("alert");
// A URL with no space to break at must still wrap inside the chip.
expect(alert.className).toContain("[overflow-wrap:anywhere]");
// A router link, not a full page reload.
const link = screen.getByRole("link", { name: /Settings/ });
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
await waitFor(() =>
expect(document.documentElement.style.getPropertyValue("--jobs-dock-h")).not.toBe("")
);
});
});
+191 -245
View File
@@ -18,7 +18,9 @@
* (`HtmlViewerModal`, its relative CSS, scripts and images read
* like downloads), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* history — the commit graph (`git/HistoryView.tsx`: all branches, search,
* details, multi-select cherry-pick/revert, compare, browse at a
* revision); Show History on a file is `git/FileHistory.tsx`
* browse — quick-open by path, or the repo's tracked file tree (the
* mode switcher's Files button; at full width the sidebar);
* a row dragged onto a prompt box attaches it as `@path`
@@ -54,9 +56,16 @@
* .gitignore (`ignore`, untracked only). While a merge is open the box is
* hidden: a partial commit cannot finish one, Complete does.
*
* History rewrites through its commit menu: Revert (any commit), Undo commit
* (the top one; the server refuses a pushed one) and, while another branch's
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
* **Shelve (n)** beside Discard sets the ticked files aside (`shelve`, with
* an optional one-line note), and the Shelves list (`LaneShelves`) above the
* file list restores or deletes what was shelved — a git stash under the
* hood, its recovery always a button here.
*
* History rewrites through HistoryView's commit menu and selection: Revert and
* Cherry-pick (one or several commits, previewed in `CherryPickDialog`, a
* paused one finished from `PickBanner`), Undo commit (the top one; the
* server refuses a pushed one), Reset branch to here… and Edit commits after
* this one… (`GitTools`). The commit row's Amend box
* folds the ticked files into the last commit (none ticked: rewords it). All
* four ask first (`confirmDialog`).
*
@@ -92,6 +101,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import {
Archive,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -142,9 +152,14 @@ import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { fetchVia } from "../HtmlFrame";
import { HtmlViewerModal } from "../HtmlViewerModal";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { LaneShelves, withAgentConfirm } from "./LaneShelves";
import { useGitChanged } from "../../lib/gitChanged";
import { GitTools, GitToolsButton, RebaseSkipButton, type GitTool } from "./git/GitTools";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach";
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
import { HistoryView } from "./git/HistoryView";
import { FileHistory } from "./git/FileHistory";
import { PickBanner } from "./git/PickBanner";
import type {
LaneChangeFile,
LaneChangeRange,
@@ -164,6 +179,10 @@ const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
type Mode = "overview" | "review" | "file" | "history" | "browse";
/** A cherry-pick or revert: its banner is PickBanner, not the merge one (a
* merge and a rebase keep theirs). */
const isPick = (op: string | null | undefined) => op === "cherry-pick" || op === "revert";
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
/** JetBrains' VCS colours: unversioned orange, added green, deleted grey,
* modified (and renamed) blue. */
@@ -771,54 +790,6 @@ function ChangeTreeNodes({
);
}
/** History's page size, and the most the server reads (`listCommits`). */
const HISTORY_PAGE = 50;
const HISTORY_MAX = 200;
// The history's commit graph: one fixed-height row per commit so its lanes
// join up row to row. Mid-tone hues that read on dark and light themes alike.
const GRAPH_ROW = 34;
const GRAPH_COL = 12;
const GRAPH_COLORS = [
"#3b82f6",
"#22c55e",
"#f59e0b",
"#a855f7",
"#ef4444",
"#14b8a6",
"#ec4899",
"#84cc16",
];
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
const y = (f: number) => f * GRAPH_ROW;
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
return (
<svg
aria-hidden
width={width * GRAPH_COL}
height={GRAPH_ROW}
className="shrink-0"
data-testid="lane-changes-graph"
>
{row.segs.map((g, i) => {
const ym = (y(g.y1) + y(g.y2)) / 2;
return (
<path
key={i}
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
fill="none"
stroke={hue(g.color)}
strokeWidth={1.5}
/>
);
})}
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
</svg>
);
}
export function LaneChangesPanel({
laneId,
session,
@@ -861,6 +832,8 @@ export function LaneChangesPanel({
const opOpen = !!(git?.available && git.merging);
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session, source);
// The open history-rewrite / remote / merge-editor tool (./git/GitTools).
const [gitTool, setGitTool] = useState<GitTool | null>(null);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback(
(text: string) => {
@@ -876,6 +849,11 @@ export function LaneChangesPanel({
// Bumped by Refresh and by a git bar action: a pull or a switch moves
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
// Any git write on this workspace — from this panel's bar, another repo's
// panel or the session view — re-reads everything here (`rereadAll`, set
// once it is defined below).
const rereadRef = useRef<(() => void) | null>(null);
useGitChanged(laneId, () => rereadRef.current?.());
const [range, setRange] = useState<LaneChangeRange>("work");
// Local mode: the files are already on this machine — no upload, download
// or File Explorer (the desktop's file manager does all three).
@@ -909,7 +887,6 @@ export function LaneChangesPanel({
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
setMode(full ? "overview" : "browse");
}, [notRepo, mode, full]);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
// on first expand, and kept so re-collapsing doesn't re-fetch.
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
@@ -979,6 +956,9 @@ export function LaneChangesPanel({
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
const pendingScroll = useRef<number | null>(null);
const [message, setMessage] = useState("");
// Shelve (n) opened its note row; the note typed there.
const [shelving, setShelving] = useState(false);
const [shelfNote, setShelfNote] = useState("");
// The commit row amends the last commit instead of making a new one.
const [amend, setAmend] = useState(false);
// The last commit is already on its upstream: the server refuses to amend
@@ -1076,30 +1056,8 @@ export function LaneChangesPanel({
}, [load, mode]);
// History and the file tree are their own reads, each fetched when its mode
// is entered rather than alongside the diff nobody is looking at.
// History reads HISTORY_PAGE commits, and Load more asks for the next page
// (up to the server's 200).
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (mode !== "history") return;
// A reply for the previous ref/file/page lands after the next request
// started: drop it, or it overwrites the list the user is looking at.
let live = true;
setHistoryLoading(true);
void api.lanes
.gitLog(target, {
limit: historyLimit,
...(historyRef ? { ref: historyRef } : {}),
...(historyFile ? { file: historyFile } : {}),
})
.then(forLane(laneId, (r) => live && setCommits(r.commits)))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)))
.finally(() => live && laneNow.current === laneId && setHistoryLoading(false));
return () => {
live = false;
};
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
// is entered rather than alongside the diff nobody is looking at. The commit
// graph and a file's history read themselves (git/HistoryView, FileHistory).
useEffect(() => {
if (mode !== "history") return;
let live = true;
@@ -1266,7 +1224,6 @@ export function LaneChangesPanel({
setTreeBusy(false);
setBusy(false);
setError(null);
setCommits(null);
setData(null);
setDraft(null);
setCollapsed(new Set());
@@ -1347,7 +1304,6 @@ export function LaneChangesPanel({
if (!ok) return;
setHistoryFile(path);
setHistoryRef(null);
setCommits(null);
setMode("history");
});
};
@@ -1514,6 +1470,36 @@ export function LaneChangesPanel({
}
};
// Shelve (n): the ticked files (whole files — a stash has no hunks) set
// aside with the note; all of them when every file is ticked.
const shelveTicked = async () => {
if (!tickedPaths.length) return;
const lane = laneId;
setBusy(true);
setError(null);
try {
const all = tickedPaths.length >= files.length;
const res = await withAgentConfirm((confirm) =>
api.lanes.gitAction(target, "shelve", {
...(all ? {} : { paths: tickedPaths }),
...(shelfNote.trim() ? { note: shelfNote.trim() } : {}),
...(confirm ? { confirm } : {}),
})
);
if (laneNow.current !== lane || !res) return;
toast.success(t("shelves.shelved", { count: res.shelf?.files.length ?? tickedPaths.length }));
setShelving(false);
setShelfNote("");
refreshGit();
setReread((n) => n + 1);
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
const mergeAct = async (
action: "resolve" | "merge-commit" | "merge-abort",
@@ -1601,7 +1587,6 @@ export function LaneChangesPanel({
const fileCtx = useContextMenu<LaneChangeFile>();
const treeCtx = useContextMenu<LaneTreeEntry>();
const commitCtx = useContextMenu<LaneCommit>();
const viewCommit = (sha: string) => {
setSha(sha);
setRange("commit");
@@ -1777,68 +1762,15 @@ export function LaneChangesPanel({
</MenuItem>
</ContextMenu>
)}
{commitCtx.menu && (
<ContextMenu
x={commitCtx.menu.x}
y={commitCtx.menu.y}
onClose={commitCtx.close}
testId="lane-changes-commit-menu"
>
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
{t("changes.viewCommit")}
</MenuItem>
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
{t("changes.copySha")}
</MenuItem>
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
{t("changes.copySubject")}
</MenuItem>
<MenuSeparator />
{historyRef ? (
<MenuItem
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
testId="lane-changes-menu-cherry-pick"
onSelect={pick(
commitCtx.close,
() => void historyAct("cherry-pick", commitCtx.menu!.item)
)}
>
{t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })}
</MenuItem>
) : (
<>
<MenuItem
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
testId="lane-changes-menu-revert"
onSelect={pick(
commitCtx.close,
() => void historyAct("revert", commitCtx.menu!.item)
)}
>
{t("changes.revertCommit")}
</MenuItem>
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
<MenuItem
danger
// Pushed: the server refuses it (EPUSHED), so say why here
// instead of failing after the confirm.
disabled={
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
}
title={headPushed ? t("changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit"
onSelect={pick(
commitCtx.close,
() => void historyAct("undo-commit", commitCtx.menu!.item)
)}
>
{t("changes.undoCommit")}
</MenuItem>
)}
</>
)}
</ContextMenu>
)}
<GitTools
tool={gitTool}
target={target}
branch={git?.available ? git.branch : null}
onClose={() => setGitTool(null)}
onOpen={setGitTool}
onChanged={() => void rereadAll()}
onViewCommit={viewCommit}
/>
</>
);
@@ -2020,6 +1952,7 @@ export function LaneChangesPanel({
setReread((n) => n + 1);
return load();
};
rereadRef.current = () => void rereadAll();
const [rereading, runReread] = useSpin(rereadAll);
// Files tab transfers (issue #78): a download is a plain attachment link; an
@@ -2419,10 +2352,7 @@ export function LaneChangesPanel({
aria-pressed={mode === m}
onClick={() => {
// The History button is the branch's; Show History, a file's.
if (m === "history" && historyFile) {
setHistoryFile(null);
setCommits(null);
}
if (m === "history" && historyFile) setHistoryFile(null);
setMode(m);
}}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
@@ -2437,6 +2367,12 @@ export function LaneChangesPanel({
</div>
)}
{!notRepo && (
<GitToolsButton
onPick={setGitTool}
hasRemote={git?.available ? git.hasRemote : undefined}
/>
)}
<button
type="button"
onClick={runReread}
@@ -2645,7 +2581,17 @@ export function LaneChangesPanel({
</p>
)}
{git?.available && git.merging && (
{git?.available && git.merging && isPick(git.operation) && (
<PickBanner
target={target}
conflicts={git.conflicts ?? 0}
reread={reread}
busy={busy}
onBusy={setBusy}
onDone={() => void rereadAll()}
/>
)}
{git?.available && git.merging && !isPick(git.operation) && (
<div
data-testid="lane-merge-banner"
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
@@ -2663,6 +2609,13 @@ export function LaneChangesPanel({
>
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
</button>
{git.operation === "rebase" && (
<RebaseSkipButton
target={target}
disabled={busy}
onChanged={() => void rereadAll()}
/>
)}
<button
type="button"
disabled={busy}
@@ -2679,6 +2632,12 @@ export function LaneChangesPanel({
</div>
)}
{/* Changes set aside (git stash entries AgentHub made): Restore and
Delete, never dropped on their own. Absent while there are none. */}
{git?.available && mode !== "file" && (
<LaneShelves target={target} reread={reread} onChanged={rereadAll} />
)}
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
bar sits at the bottom of the visible box instead of at the bottom of
a 2000-line container nobody has scrolled to yet. Every other mode is
@@ -2691,103 +2650,40 @@ export function LaneChangesPanel({
}`}
>
{mode === "history" ? (
<ul data-testid="lane-changes-history">
{historyFile && (
<li
data-testid="lane-changes-history-file"
className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
>
<span className="min-w-0 flex-1 truncate">
{t("changes.historyOfFile", { path: historyFile })}
</span>
<button
type="button"
onClick={() => {
setHistoryFile(null);
setCommits(null);
}}
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("changes.historyWholeBranch")}
</button>
</li>
)}
{historyBranches.length > 1 && (
<li className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary">
<span className="shrink-0">{t("changes.historyOf")}</span>
<select
data-testid="lane-changes-history-ref"
value={historyRef ?? ""}
onChange={(e) => {
setHistoryRef(e.target.value || null);
setCommits(null);
}}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
>
<option value="">{git?.available ? git.branch : "HEAD"}</option>
{historyBranches
.filter((b) => !(git?.available && b === git.branch))
.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</li>
)}
{(() => {
const list = commits ?? [];
const graph = commitGraph(list);
return list.map((c, i) => (
<li key={c.sha}>
<button
type="button"
onClick={() =>
historyFile ? openAtCommit(historyFile, c.sha) : viewCommit(c.sha)
}
onContextMenu={(e) => commitCtx.open(e, c)}
style={{ height: GRAPH_ROW }}
className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
>
<GraphCell row={graph.rows[i]!} width={graph.width} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
{c.short}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
{c.subject}
</span>
</span>
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</span>
</button>
</li>
));
})()}
{commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)}
{commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)}
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
<li>
<button
type="button"
data-testid="lane-changes-history-more"
disabled={historyLoading}
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
>
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
</button>
</li>
)}
</ul>
historyFile ? (
<FileHistory
key={`${laneId}:${historyFile}`}
target={target}
path={historyFile}
reread={reread}
onOpenVersion={openAtCommit}
onViewCommit={viewCommit}
onWholeBranch={() => setHistoryFile(null)}
/>
) : (
<HistoryView
key={laneId}
target={target}
branch={git?.available ? git.branch : null}
branches={historyBranches}
historyRef={historyRef}
onHistoryRef={setHistoryRef}
reread={reread}
disabled={busy || opOpen}
headPushed={headPushed}
onViewCommit={viewCommit}
onOpenFileAtCommit={openAtCommit}
onUndoCommit={(c) => void historyAct("undo-commit", c)}
onResetHere={(c) => setGitTool({ kind: "reset", commit: c })}
onEditAfter={(sha) => setGitTool({ kind: "interactive", base: sha })}
onPaused={() => {
setRange("work");
setMode("overview");
void rereadAll();
}}
onChanged={() => void rereadAll()}
/>
)
) : mode === "browse" && !full ? (
browsePane
) : !data ? (
@@ -2893,7 +2789,54 @@ export function LaneChangesPanel({
<Trash2 className="h-3 w-3" aria-hidden />
{t("changes.discardTicked", { count: tickedPaths.length })}
</button>
<button
type="button"
data-testid="lane-changes-shelve"
aria-expanded={shelving}
disabled={busy || tickedPaths.length === 0}
onClick={() => setShelving((v) => !v)}
title={t("shelves.shelveHint")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Archive className="h-3 w-3" aria-hidden />
{t("shelves.shelveTicked", { count: tickedPaths.length })}
</button>
</div>
{shelving && (
<div
data-testid="lane-changes-shelve-row"
className="flex w-full flex-wrap items-center gap-1.5"
>
<input
value={shelfNote}
autoFocus
maxLength={200}
onChange={(e) => setShelfNote(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) void shelveTicked();
if (e.key === "Escape") setShelving(false);
}}
placeholder={t("shelves.notePlaceholder")}
aria-label={t("shelves.notePlaceholder")}
className="min-w-[10rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
disabled={busy || tickedPaths.length === 0}
onClick={() => void shelveTicked()}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{t("shelves.shelveApply", { count: tickedPaths.length })}
</button>
<button
type="button"
onClick={() => setShelving(false)}
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("shelves.cancel")}
</button>
</div>
)}
</div>
)}
{/* The work range splits like JetBrains' Commit window: tracked
@@ -3014,6 +2957,9 @@ export function LaneChangesPanel({
editWanted.current = true;
setWhole(true);
}}
onMergeEditor={() =>
setGitTool({ kind: "merge", path: openFile, onResolved: goBack })
}
/>
) : mode === "file" ? (
current || (whole && text) || openImage ? (
@@ -8,7 +8,8 @@
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
* `resolve done`, which re-checks that no marker is left. Take all mine /
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
* file to the panel's plain editor.
* file to the panel's plain editor; Merge editor opens the 3-way editor
* (`./git/MergeEditor`, base/current/incoming side by side).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -42,7 +43,10 @@ export function LaneConflictView({
path,
onResolved,
onEdit,
onMergeEditor,
}: {
/** Open the 3-way merge editor (./git/MergeEditor) on this file. */
onMergeEditor?: () => void;
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
@@ -176,6 +180,17 @@ export function LaneConflictView({
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
{t("conflict.editByHand")}
</button>
{onMergeEditor && (
<button
type="button"
data-testid="conflict-open-merge-editor"
className={BTN}
disabled={busy}
onClick={onMergeEditor}
>
{t("gitTools:merge.open")}
</button>
)}
</div>
{error && (
+153 -17
View File
@@ -15,7 +15,16 @@
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
* worktree`), and these are the ordinary moves a lane needs several times an hour.
* Nothing here decides what an action may do; the server does (pull is always
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
* --ff-only, switch refuses a dirty tree unless told what to do with the
* changes), and this only reports what it said. With uncommitted changes,
* Switch branch asks first — **Bring my changes** (`changes: "carry"`; a
* file git would overwrite comes back as ECARRYCONFLICT, listed, with Shelve
* and switch offered instead) or **Shelve and switch** (`changes: "shelve"`)
* — and the new-branch row offers **Create & switch** (`switch: true`, the
* changes come along; the default on Enter while the tree is dirty) beside
* plain Create. A switch onto a branch that has shelves asks whether to
* restore the newest (never automatically), and a write the server answers
* EAGENTRUNNING is confirmed and resent (`withAgentConfirm`'s dialog).
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
* the user more than any sentence this file could write. The one exception
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
@@ -23,6 +32,7 @@
* resent with the typed credential — the token is never rendered back.
* Fetch, pull, push and publish say so in a toast when they succeed; a merge,
* a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel.
* The tags icon opens the Branches panel (`git/BranchManager`: every branch and tag, rename, upstream, tags).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -45,6 +55,8 @@ import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
import { restoreShelf } from "./LaneShelves";
import { BranchesButton } from "./git/BranchesButton";
type Action =
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
@@ -128,6 +140,9 @@ export function LaneGitBar({
const [remember, setRemember] = useState(true);
const [publishing, setPublishing] = useState(false);
const [url, setUrl] = useState("");
// A switch with uncommitted changes asks how to take them: set to the
// branch picked (and the files git refused to carry, after ECARRYCONFLICT).
const [choice, setChoice] = useState<{ branch: string; conflict?: string[] } | null>(null);
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
// All of the state above describes ONE lane — a branch picked for lane A,
// applied after switching, would move B's HEAD — so it is reset during
@@ -150,6 +165,7 @@ export function LaneGitBar({
setPassword("");
setPublishing(false);
setUrl("");
setChoice(null);
inFlight.current = false;
}
const laneNow = useRef(laneId);
@@ -168,6 +184,9 @@ export function LaneGitBar({
const lane = laneId;
setBusy(action);
setError(null);
// EAGENTRUNNING: asked AFTER the request settles, so the resend is not
// refused as a second action in flight.
let askAgent = false;
try {
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
// Landed after a lane switch: every write below belongs to the lane
@@ -190,20 +209,50 @@ export function LaneGitBar({
if (action === "switch") setSwitching(false);
else setNewBranch("");
setPicked("");
setChoice(null);
setBranches(null);
await refreshBranches();
} else if ((action === "fetch" || action === "pull") && branches !== null) {
// A fetch may have brought branches the list does not show yet.
await refreshBranches();
}
if (res?.shelved) toast.success(t("gitBar.shelvedOn", { branch: res.shelved.branch }));
onChanged();
// The branch switched to has changes shelved on it: offer them back —
// asking, never restoring on its own.
const offer = action === "switch" ? res?.shelves?.[0] : undefined;
if (offer) {
const yes = await confirmDialog({
title: t("gitBar.restoreOfferTitle"),
message: t("gitBar.restoreOffer", {
branch: offer.branch,
count: res?.shelves?.length ?? 1,
files: offer.files.length,
}),
confirmLabel: t("shelves.restore"),
destructive: false,
});
if (yes && laneNow.current === lane) {
await restoreShelf(gitTarget(lane, session, source), offer);
if (laneNow.current === lane) onChanged();
}
}
} catch (err) {
// The server appends git's own stderr to the message, so this is the
// rejection text verbatim — hence the <pre> below rather than a line.
// ENOREMOTE is the one refusal with no git text to show: the server's
// sentence is English and says less than a translated one.
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
const code = (err as { code?: string })?.code;
if (laneNow.current === lane && code === "EAGENTRUNNING" && body?.confirm !== true) {
askAgent = true;
} else if (laneNow.current === lane && code === "ECARRYCONFLICT" && body?.branch) {
// git would overwrite these files: show them and offer the shelf.
setChoice({
branch: body.branch,
conflict: (err as { files?: string[] }).files ?? [],
});
} else if (laneNow.current === lane && code === "EGITAUTH" && auth) {
setLogin({ action, auth });
setUsername((u) => u || auth.username || "");
setPassword("");
@@ -221,6 +270,15 @@ export function LaneGitBar({
setBusy(null);
}
}
if (askAgent) {
const ok = await confirmDialog({
title: t("shelves.agentTitle"),
message: t("shelves.agentMessage"),
confirmLabel: t("shelves.agentConfirm"),
destructive: false,
});
if (ok && laneNow.current === lane) void run(action, { ...body, confirm: true });
}
};
const refreshBranches = async () => {
@@ -306,6 +364,20 @@ export function LaneGitBar({
if (ok) void run("merge", { branch });
};
// Switch branch: straight away on a clean tree; with changes, ask how.
const switchTo = (branch: string) => {
if (dirty > 0) {
setChoice({ branch });
setError(null);
} else void run("switch", { branch });
};
// Enter in the new-branch box: with changes, Create & switch (they come
// along); on a clean tree, plain Create as before.
const createBranch = (andSwitch: boolean) => {
const branch = newBranch.trim();
if (branch) void run("branch-create", andSwitch ? { branch, switch: true } : { branch });
};
const heldBy = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
@@ -424,17 +496,30 @@ export function LaneGitBar({
label={t("gitBar.switch")}
icon={<GitBranch className="h-3.5 w-3.5" />}
disabled={!!busy || merging}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
// With changes, Switch branch asks whether to bring or shelve
// them; the hint says so before the click.
title={
merging
? t("gitBar.merging", { op: git.operation ?? "merge" })
: dirty > 0
? t("gitBar.switchDirty")
? t("gitBar.switchDirtyHint", { count: dirty })
: t("gitBar.switchHint")
}
onClick={openSwitch}
/>
<BranchesButton
laneId={laneId}
session={session}
source={source}
dirty={dirty}
merging={merging}
disabled={!!busy}
onChanged={() => {
// A rename, delete or new branch makes the switcher's list stale.
if (branches !== null) void refreshBranches();
onChanged();
}}
/>
</div>
)}
</div>
@@ -473,9 +558,11 @@ export function LaneGitBar({
busy={busy === "switch"}
// A branch git already has checked out somewhere else cannot be
// switched to — git allows one worktree per branch.
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={() => void run("switch", { branch: picked })}
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
title={
dirty > 0 ? t("gitBar.switchDirtyHint", { count: dirty }) : t("gitBar.switchHint")
}
onClick={() => switchTo(picked)}
/>
<Btn
label={t("gitBar.merge")}
@@ -521,34 +608,83 @@ export function LaneGitBar({
)
}
/>
{dirty > 0 && (
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
</div>
)}
{switching && choice && (
<div
data-testid="lane-git-switch-choice"
role="group"
aria-label={t("gitBar.choiceTitle", { branch: choice.branch })}
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
>
<p className="break-words text-fg-primary">
{choice.conflict
? t("gitBar.carryConflict", { branch: choice.branch })
: t("gitBar.choiceMessage", { count: dirty, branch: choice.branch })}
</p>
{choice.conflict && choice.conflict.length > 0 && (
<ul
data-testid="lane-git-carry-conflicts"
className="max-h-24 overflow-auto font-mono text-[10px] text-status-warning"
>
{choice.conflict.map((f) => (
<li key={f} className="truncate" title={f}>
{f}
</li>
))}
</ul>
)}
<div className="flex flex-wrap gap-1.5">
{!choice.conflict && (
<Btn
label={t("gitBar.choiceCarry")}
busy={busy === "switch"}
title={t("gitBar.choiceCarryHint")}
onClick={() => void run("switch", { branch: choice.branch, changes: "carry" })}
/>
)}
<Btn
label={t("gitBar.choiceShelve")}
busy={busy === "switch"}
title={t("gitBar.choiceShelveHint")}
onClick={() => void run("switch", { branch: choice.branch, changes: "shelve" })}
/>
<Btn label={t("gitBar.loginCancel")} onClick={() => setChoice(null)} />
</div>
</div>
)}
{switching && (
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<input
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && newBranch.trim())
void run("branch-create", { branch: newBranch.trim() });
if (isSubmitEnter(e)) createBranch(dirty > 0);
}}
placeholder={t("gitBar.branchNewPlaceholder")}
aria-label={t("gitBar.branchNewPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
className="min-w-[8rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<Btn
label={t("gitBar.branchCreateSwitch")}
icon={<GitBranch className="h-3 w-3" />}
busy={busy === "branch-create"}
disabled={!newBranch.trim()}
// The working tree stays as it is: uncommitted changes come along.
title={t("gitBar.branchCreateSwitchHint")}
onClick={() => createBranch(true)}
/>
<Btn
label={t("gitBar.branchCreate")}
icon={<Plus className="h-3 w-3" />}
busy={busy === "branch-create"}
disabled={!newBranch.trim()}
// Creating does not switch: the point is usually a sibling lane,
// and moving this pane's HEAD under a working agent is not.
// Plain Create does not switch: the point is usually a sibling
// lane, and moving this pane's HEAD under a working agent is not.
title={t("gitBar.branchCreateHint")}
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
onClick={() => createBranch(false)}
/>
</div>
)}
@@ -10,7 +10,9 @@
* It used to sit in the lane card, a column away from the diff it describes —
* a pull there changed files the Changes panel only noticed on its next poll.
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
* owned by the panel) and refresh each other after every write.
* owned by the panel) and refresh each other after every write. Every other
* reader of the facts (the rail counts, a second panel) hears a write through
* `agenthub:git-changed` (lib/gitChanged.ts), which `useLaneGitFacts` listens to.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -21,6 +23,7 @@ import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api, gitTarget, type GitSource } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import { useGitChanged } from "../../lib/gitChanged";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
/** How often the mounted panel re-reads its working-copy facts. Slow on
@@ -76,6 +79,9 @@ export function useLaneGitFacts(
clearInterval(timer);
};
}, [laneId, session, source, nonce]);
// A git write from ANY view of this workspace (another panel, the session
// view, a second tab of the same page) moves these facts too.
useGitChanged(laneId, () => setNonce((n) => n + 1));
return [facts, () => setNonce((n) => n + 1)];
}
+241
View File
@@ -0,0 +1,241 @@
/**
* @file The Changes panel's Shelves list: changes set aside by AgentHub (git
* stash entries it made — `GET /api/lanes/:id/git/shelves`), each with its
* branch, note, time and files, and the two buttons that make a shelf safe to
* offer from a dashboard: **Restore** (`unshelve`, which drops the shelf only
* when it applied cleanly; a conflicted restore keeps it and leaves the files
* to the conflict list) and **Delete** (`shelf-drop`, two clicks through
* `useArmed`, sent with `confirm: true`). Collapsed to one line until opened,
* and absent while there are none. Also exports `withAgentConfirm`, the
* one place a git write answered `EAGENTRUNNING` asks the user and is resent
* with `confirm: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Archive, ChevronDown, ChevronRight, RotateCcw, Trash2 } from "lucide-react";
import i18n from "../../i18n";
import { api, type GitShelf, type GitTarget } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
/**
* Run a git write; when the server answers `EAGENTRUNNING` (an agent of the
* workspace is working and the write would move files under it), ask, and
* resend it with `confirm: true`. Resolves null when the user declined.
*/
export async function withAgentConfirm<T>(
send: (confirm: boolean) => Promise<T>
): Promise<T | null> {
try {
return await send(false);
} catch (err) {
if ((err as { code?: string })?.code !== "EAGENTRUNNING") throw err;
const ok = await confirmDialog({
title: i18n.t("lanes:shelves.agentTitle"),
message: i18n.t("lanes:shelves.agentMessage"),
confirmLabel: i18n.t("lanes:shelves.agentConfirm"),
destructive: false,
});
return ok ? send(true) : null;
}
}
/**
* Restore one shelf (`unshelve`), reporting the outcome in a toast. Shared by
* the list below and the git bar's "restore what you shelved here" prompt.
* @returns true when anything changed in the working tree.
*/
export async function restoreShelf(target: GitTarget, shelf: GitShelf): Promise<boolean> {
try {
const res = await withAgentConfirm((confirm) =>
api.lanes.gitAction(target, "unshelve", { sha: shelf.sha, ...(confirm ? { confirm } : {}) })
);
if (!res) return false;
if (res.conflicts?.length) {
toast.info(i18n.t("lanes:shelves.restoredConflicts", { count: res.conflicts.length }));
} else {
toast.success(i18n.t("lanes:shelves.restored"));
}
return true;
} catch (err) {
toast.error(
(err as { code?: string })?.code === "EDIRTY"
? i18n.t("lanes:shelves.restoreDirty")
: err instanceof Error
? err.message
: String(err)
);
return false;
}
}
/** "M", "A", "D", "??" → a colour, matching the Changes list's letters. */
function statusTone(status: string): string {
if (status === "??" || status.startsWith("A")) return "text-status-success";
if (status.startsWith("D")) return "text-status-danger";
return "text-status-warning";
}
export function LaneShelves({
target,
reread,
onChanged,
}: {
target: GitTarget;
/** Bumped by the panel after every git write: the list is read again. */
reread: number;
/** Called after a restore or a delete changed the tree or the list. */
onChanged: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [shelves, setShelves] = useState<GitShelf[]>([]);
const [open, setOpen] = useState(false);
const [filesOf, setFilesOf] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const del = useArmed();
const key = JSON.stringify(target);
const keyNow = useRef(key);
keyNow.current = key;
const load = useCallback(async () => {
const asked = key;
try {
const r = await api.lanes.gitShelves(target);
if (keyNow.current === asked) setShelves(r.available ? r.shelves : []);
} catch {
if (keyNow.current === asked) setShelves([]);
}
// `target` is described by `key`; a new object with the same content is
// the same tree.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(() => {
void load();
}, [load, reread]);
if (!shelves.length) return null;
const restore = async (s: GitShelf) => {
setBusy(s.sha);
try {
if (await restoreShelf(target, s)) onChanged();
await load();
} finally {
setBusy(null);
}
};
const drop = async (s: GitShelf) => {
if (!del.isArmed(s.sha)) {
del.arm(s.sha);
return;
}
del.disarm();
setBusy(s.sha);
try {
await api.lanes.gitAction(target, "shelf-drop", { sha: s.sha, confirm: true });
toast.success(t("shelves.deleted"));
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
await load();
}
};
return (
<section data-testid="lane-shelves" className="mb-2 rounded border border-border-light">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1 px-2 py-1 text-left text-[11px] font-medium text-fg-secondary hover:bg-surface-2"
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Archive className="h-3 w-3 text-fg-muted" aria-hidden />
{t("shelves.title", { count: shelves.length })}
</button>
{open && (
<ul className="divide-y divide-border-light border-t border-border-light">
{shelves.map((s) => {
const when = s.createdAt ? new Date(s.createdAt) : null;
const showFiles = filesOf === s.sha;
return (
<li key={s.sha} data-testid="lane-shelf" className="px-2 py-1 text-[11px]">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span
className="min-w-0 max-w-full truncate font-mono font-semibold text-fg-primary"
title={s.branch}
>
{s.branch}
</span>
{when && !Number.isNaN(when.getTime()) && (
<span className="shrink-0 text-fg-muted" title={s.createdAt ?? undefined}>
{when.toLocaleString()}
</span>
)}
<button
type="button"
aria-expanded={showFiles}
onClick={() => setFilesOf(showFiles ? null : s.sha)}
className="shrink-0 text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
>
{t("shelves.files", { count: s.files.length })}
</button>
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
type="button"
disabled={!!busy}
onClick={() => void restore(s)}
title={t("shelves.restoreHint")}
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCcw className="h-3 w-3" aria-hidden />
{t("shelves.restore")}
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void drop(s)}
title={t("shelves.deleteHint")}
className="flex items-center gap-1 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{del.isArmed(s.sha) ? t("shelves.deleteConfirm") : t("shelves.delete")}
</button>
</div>
</div>
{s.note && (
<p className="mt-0.5 break-words text-fg-secondary" title={s.note}>
{s.note}
</p>
)}
{showFiles && (
<ul data-testid="lane-shelf-files" className="mt-1 space-y-0.5 pl-2">
{s.files.map((f) => (
<li key={f.path} className="flex min-w-0 items-center gap-1.5 font-mono">
<span className={`w-4 shrink-0 text-center ${statusTone(f.status)}`}>
{f.status === "??" ? "U" : f.status.slice(0, 1)}
</span>
<span className="min-w-0 truncate text-fg-secondary" title={f.path}>
{f.path}
</span>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</section>
);
}
export default LaneShelves;
@@ -22,6 +22,7 @@
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useGitChanged } from "../../lib/gitChanged";
import { endRuns } from "../../lib/stopSession";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -238,6 +239,17 @@ function HomeBody({
live = false;
};
}, [lane.id]);
// A branch moved from the git bar: the main worktree's branch shown here is
// the server's cached list, so it is refreshed rather than re-read.
useGitChanged(lane.id, (action) => {
if (!["switch", "branch-create", "branch-delete"].includes(action)) return;
api.projects
.refresh()
.then(({ projects }) =>
setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null)
)
.catch(() => undefined);
});
// The AI menu's Open terminal: a plain shell in the first free slot, opened
// at once — the same start the session view's tab bar `+` makes.
@@ -0,0 +1,296 @@
/**
* @file BranchManager.test.tsx
* @description Pins the git bar's Branches panel: local branches with the
* current one marked and ahead/behind shown, remote branches grouped by
* remote, the search box, arrow-key walking, and each action's request —
* rename, new branch from a remote branch (switch only offered on a clean
* tree), set/unset upstream, merge into current, delete refused with its
* reason — plus the Tags tab: annotated create, push one tag, delete with
* `confirm: true` after the themed confirm, and git's error shown verbatim.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BranchManager } from "../git/BranchManager";
import { api, type GitRefs } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number) => lane,
api: { lanes: { gitRefs: vi.fn(), gitRefAction: vi.fn() } },
}));
const REFS: GitRefs = {
available: true,
current: "main",
head: "aaaaaaa",
truncated: false,
local: [
{
name: "main",
sha: "a1",
current: true,
upstream: "origin/main",
ahead: 2,
behind: 1,
gone: false,
subject: "latest work",
date: null,
merged: true,
worktree: null,
protected: true,
},
{
name: "feature/very-long-branch-name",
sha: "b2",
current: false,
upstream: null,
ahead: null,
behind: null,
gone: false,
subject: "feature commit",
date: null,
merged: false,
worktree: null,
protected: false,
},
{
name: "held",
sha: "c3",
current: false,
upstream: null,
ahead: null,
behind: null,
gone: false,
subject: "x",
date: null,
merged: true,
worktree: "/tmp/other",
protected: false,
},
],
remotes: [
{
remote: "origin",
branches: [
{ name: "origin/main", branch: "main", sha: "a1", subject: "s", date: null },
{ name: "origin/remote-only", branch: "remote-only", sha: "d4", subject: "r", date: null },
],
},
],
tags: [
{
name: "v1",
sha: "a1b2c3d4",
annotated: true,
message: "release one",
tagger: "T",
date: null,
},
],
};
function setup(over: { dirty?: number } = {}) {
const onChanged = vi.fn();
render(
<BranchManager
laneId={3}
dirty={over.dirty ?? 0}
merging={false}
onClose={vi.fn()}
onChanged={onChanged}
/>
);
return { onChanged };
}
beforeEach(() => {
vi.mocked(api.lanes.gitRefs).mockReset().mockResolvedValue(REFS);
vi.mocked(api.lanes.gitRefAction)
.mockReset()
.mockResolvedValue({ action: "x", ok: true, output: "" });
vi.mocked(confirmDialog).mockClear();
});
const row = (name: RegExp) => screen.getByRole("button", { name });
describe("BranchManager", () => {
it("lists local branches with the current marked and remote ones grouped", async () => {
setup();
await screen.findByText("feature/very-long-branch-name");
const main = row(/^main/);
expect(within(main).getByText("current")).toBeInTheDocument();
expect(main).toHaveTextContent("2");
expect(main).toHaveTextContent("1");
expect(screen.getByText("Remote · origin")).toBeInTheDocument();
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
// A long name truncates with its full text as the tooltip.
expect(screen.getByText("feature/very-long-branch-name")).toHaveAttribute(
"title",
"feature/very-long-branch-name"
);
});
it("filters by the search box and walks rows with the arrow keys", async () => {
setup();
await screen.findByText("held");
await userEvent.type(screen.getByRole("textbox", { name: "Search branches" }), "remote");
expect(screen.queryByText("held")).toBeNull();
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
await userEvent.clear(screen.getByRole("textbox", { name: "Search branches" }));
await userEvent.keyboard("{ArrowDown}");
expect(row(/^main/)).toHaveFocus();
await userEvent.keyboard("{ArrowDown}");
expect(row(/^feature\/very-long/)).toHaveFocus();
});
it("renames a branch and re-reads the list", async () => {
const { onChanged } = setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
const input = screen.getByRole("textbox", { name: "New name" });
await userEvent.clear(input);
await userEvent.type(input, "feature/short{Enter}");
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-rename", {
branch: "feature/very-long-branch-name",
name: "feature/short",
});
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(api.lanes.gitRefs).toHaveBeenCalledTimes(2);
});
it("refuses rename and delete of a protected current branch, with the reason", async () => {
setup();
await userEvent.click(await screen.findByText("main"));
expect(screen.getByRole("button", { name: /^Rename$/ })).toBeDisabled();
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
expect(screen.getByText(/You are on this branch/)).toBeInTheDocument();
});
it("explains a branch another worktree holds", async () => {
setup();
await userEvent.click(await screen.findByText("held"));
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
expect(screen.getByText(/another worktree: \/tmp\/other/)).toBeInTheDocument();
});
it("deletes an unmerged branch only after the confirm warns", async () => {
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Delete$/ }));
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0].message).toMatch(/not merged/);
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-delete", {
branch: "feature/very-long-branch-name",
});
});
it("creates a branch from a remote branch, switch offered only on a clean tree", async () => {
setup({ dirty: 2 });
await userEvent.click(await screen.findByText("origin/remote-only"));
await userEvent.click(screen.getByRole("button", { name: /New branch from here/ }));
expect(screen.getByRole("checkbox", { name: "Switch to it" })).toBeDisabled();
const input = screen.getByRole("textbox", { name: "New branch name" });
expect(input).toHaveValue("remote-only");
await userEvent.type(input, "{Enter}");
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-create", {
branch: "remote-only",
from: "origin/remote-only",
});
});
it("sets and unsets an upstream, and merges into the current branch", async () => {
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /Set upstream/ }));
await userEvent.selectOptions(screen.getByRole("combobox"), "origin/remote-only");
await userEvent.click(screen.getByRole("button", { name: /Apply/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", {
branch: "feature/very-long-branch-name",
upstream: "origin/remote-only",
});
await userEvent.click(screen.getByRole("button", { name: /Merge into main/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "merge", {
branch: "feature/very-long-branch-name",
});
await userEvent.click(screen.getByText("main"));
await userEvent.click(screen.getByRole("button", { name: /Unset upstream/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", { branch: "main" });
});
it("shows git's own words when a write is refused", async () => {
vi.mocked(api.lanes.gitRefAction).mockRejectedValueOnce(
Object.assign(new Error("branch x already exists"), { code: "EBRANCHEXISTS" })
);
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
await userEvent.type(screen.getByRole("textbox", { name: "New name" }), "x{Enter}");
expect(await screen.findByTestId("branch-manager-error")).toHaveTextContent(
"branch x already exists"
);
});
});
describe("TagManager (Tags tab)", () => {
const openTags = async () => {
setup();
await screen.findByText("main");
await userEvent.click(screen.getByRole("tab", { name: /Tags/ }));
};
it("lists tags with their message and creates an annotated one", async () => {
await openTags();
expect(screen.getByText("v1")).toBeInTheDocument();
expect(screen.getByText(/release one/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /New tag/ }));
await userEvent.type(screen.getByRole("textbox", { name: /Tag name/ }), "v2");
await userEvent.type(
screen.getByRole("textbox", { name: /^On / }),
"feature/very-long-branch-name"
);
await userEvent.type(screen.getByRole("textbox", { name: /Message/ }), "notes");
await userEvent.click(screen.getByRole("button", { name: "Create annotated tag" }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-create", {
tag: "v2",
message: "notes",
from: "feature/very-long-branch-name",
});
});
it("pushes one tag and deletes one only with confirm: true", async () => {
await openTags();
await userEvent.click(screen.getByText("v1"));
await userEvent.click(screen.getByRole("button", { name: /Push to origin/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-push", { tag: "v1" });
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
expect(vi.mocked(confirmDialog).mock.lastCall?.[0].destructive).toBe(true);
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-delete", {
tag: "v1",
confirm: true,
});
});
it("opens from the git bar's button and closes on Escape", async () => {
const { BranchesButton } = await import("../git/BranchesButton");
render(<BranchesButton laneId={3} dirty={0} merging={false} onChanged={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Manage branches & tags" }));
expect(await screen.findByTestId("branch-manager")).toBeInTheDocument();
await screen.findByText("main");
await userEvent.keyboard("{Escape}");
expect(screen.queryByTestId("branch-manager")).toBeNull();
});
it("does not delete when the confirm is cancelled", async () => {
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await openTags();
await userEvent.click(screen.getByText("v1"));
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
expect(api.lanes.gitRefAction).not.toHaveBeenCalled();
});
});
@@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event";
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import { emitGitChanged } from "../../../lib/gitChanged";
import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
@@ -84,6 +85,19 @@ vi.mock("../../../lib/api", () => ({
gitDownloadUrl: (id: number, path: string) =>
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
gitUpload: vi.fn(),
gitHistory: {
commit: vi.fn(),
patch: vi.fn(),
fileHistory: vi.fn(),
blameAt: vi.fn(),
treeAt: vi.fn(),
compare: vi.fn(),
compareFile: vi.fn(),
pickPlan: vi.fn(),
sequence: vi.fn(),
pick: vi.fn(),
act: vi.fn(),
},
},
},
}));
@@ -153,6 +167,44 @@ function discardOf(path: string) {
beforeEach(() => {
perms.local = true;
const h = api.lanes.gitHistory;
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
vi.mocked(h.pick).mockReset();
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
available: true,
op,
commits: shas.map((sha) => ({
sha,
short: sha.slice(0, 7),
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
})),
branch: "feat/thing",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(h.fileHistory)
.mockReset()
.mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
hasMore: false,
commits: [
{
sha: "a".repeat(40),
short: "aaaaaaa",
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
parents: [],
status: "M",
path: "server/lib/git-ops.js",
},
],
});
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -445,6 +497,84 @@ describe("LaneChangesPanel", () => {
);
});
it("shows the NEW branch everywhere after a switch from the git bar", async () => {
// The repo as the server sees it: which branch, which changes.
let branch = "feat/thing";
vi.mocked(api.lanes.git).mockImplementation(async () => ({
available: true,
branch,
head: "9b3e74a",
subject: "wip",
dirty: 0,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
}));
vi.mocked(api.lanes.changes).mockImplementation(async () =>
changes({
files: [
{
path: branch === "other" ? "only-on-other.ts" : "only-on-thing.ts",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
vi.mocked(api.lanes.gitAction).mockImplementation(async (_t, action, body) => {
if (action === "switch") branch = (body as { branch: string }).branch;
return { action, ok: true, output: "", shelves: [] };
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
await screen.findByTitle("only-on-thing.ts");
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
expect(await screen.findByTitle("only-on-other.ts")).toBeInTheDocument();
expect(screen.queryByTitle("only-on-thing.ts")).toBeNull();
});
it("re-reads when ANOTHER view of the lane changed its git state", async () => {
let branch = "feat/thing";
vi.mocked(api.lanes.git).mockImplementation(async () => ({
available: true,
branch,
head: "9b3e74a",
subject: "wip",
dirty: 0,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
}));
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
const before = vi.mocked(api.lanes.changes).mock.calls.length;
branch = "other";
act(() => emitGitChanged(7, "switch"));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before);
// Another workspace's write is not this panel's business.
const now = vi.mocked(api.lanes.git).mock.calls.length;
act(() => emitGitChanged(8, "switch"));
await new Promise((r) => setTimeout(r, 20));
expect(vi.mocked(api.lanes.git).mock.calls.length).toBe(now);
});
it("re-reads history too when Refresh is pressed there", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -962,10 +1092,11 @@ describe("LaneChangesPanel", () => {
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
);
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 50,
file: "server/lib/git-ops.js",
})
expect(api.lanes.gitHistory.fileHistory).toHaveBeenLastCalledWith(
7,
"server/lib/git-ops.js",
{ limit: 100 }
)
);
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
"server/lib/git-ops.js"
@@ -1092,7 +1223,7 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "History" }));
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
await user.click(await screen.findByText("feat: the commit"));
await user.dblClick(await screen.findByText("feat: the commit"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
@@ -1150,8 +1281,21 @@ describe("LaneChangesPanel", () => {
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
action: "revert",
ok: true,
output: "",
conflicts: [],
paused: false,
from: "b".repeat(40),
to: "c".repeat(40),
commits: [{ sha: "c".repeat(40), short: "ccccccc", subject: 'Revert "x"' }],
});
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "revert", { sha: "a".repeat(40) });
await user.click(await screen.findByTestId("pick-confirm"));
await waitFor(() =>
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "revert", ["a".repeat(40)])
);
await user.click(within(await commitMenu(user)).getByText("Undo commit (keep changes)"));
await waitFor(() =>
@@ -1188,10 +1332,22 @@ describe("LaneChangesPanel", () => {
await user.selectOptions(await screen.findByTestId("lane-changes-history-ref"), "other");
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 50, ref: "other" })
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 100, ref: "other" })
);
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
action: "cherry-pick",
ok: true,
output: "",
conflicts: [],
paused: false,
});
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "cherry-pick", { sha: "a".repeat(40) });
await user.click(await screen.findByTestId("pick-confirm"));
await waitFor(() =>
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
"a".repeat(40),
])
);
});
it("asks before a revert and an amend, and a No sends nothing", async () => {
@@ -1207,9 +1363,11 @@ describe("LaneChangesPanel", () => {
expect(confirmDialog).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "History" }));
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
// A revert previews what it will do; Cancel sends nothing.
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(2));
await screen.findByTestId("pick-plan");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
@@ -3,8 +3,10 @@
* @description Pins the lane git action row: the ahead/behind readout, which
* buttons a repo with no remote may press (and that it may publish to a typed URL),
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
* refuses it too), that branches only the remote has are offered (and the list
* Changes panel's staged-only box owns that), that a dirty tree's switch asks
* whether to bring the changes or shelve them (and offers the shelf when git
* refuses to carry them), Create & switch, the offer to restore a branch's
* shelf after switching to it, the confirm-and-resend of EAGENTRUNNING, that branches only the remote has are offered (and the list
* is re-read after a fetch), and that a failed action shows git's own words rather than
* a generic sentence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -202,16 +204,114 @@ describe("LaneGitBar", () => {
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
});
it("loads the branch list on demand and refuses to switch while dirty", async () => {
it("loads the branch list on demand and asks how to take the changes while dirty", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
// Picking is harmless; applying is what a dirty tree blocks.
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Switch branch" }));
// Nothing is sent before the user picks what happens to the changes.
expect(api.lanes.gitAction).not.toHaveBeenCalled();
const choice = screen.getByTestId("lane-git-switch-choice");
expect(choice).toHaveTextContent("3 uncommitted file(s)");
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
branch: "development",
changes: "carry",
})
);
});
it("offers Shelve and switch when git refuses to carry the changes", async () => {
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
Object.assign(new Error("would be overwritten"), {
code: "ECARRYCONFLICT",
files: ["src/a-very-long-path/that/should/truncate.ts"],
})
);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
"src/a-very-long-path/that/should/truncate.ts"
);
expect(screen.queryByRole("button", { name: "Bring my changes" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
branch: "development",
changes: "shelve",
})
);
});
it("asks before moving files under a working agent, then resends with confirm", async () => {
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
Object.assign(new Error("agent"), { code: "EAGENTRUNNING" })
);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
await waitFor(() =>
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: "An agent is working here" })
)
);
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
branch: "development",
changes: "shelve",
confirm: true,
})
);
});
it("offers to restore the shelf of the branch switched to, and restores it on Yes", async () => {
const shelf = {
ref: "stash@{0}",
sha: "abc1234def",
branch: "development",
note: "",
createdAt: "2026-11-02T00:00:00.000Z",
files: [{ path: "a.txt", status: "M" }],
};
vi.mocked(api.lanes.gitAction)
.mockResolvedValueOnce({ action: "switch", ok: true, output: "", shelves: [shelf] })
.mockResolvedValueOnce({ action: "unshelve", ok: true, output: "", conflicts: [] });
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
);
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: "Restore shelved changes?" })
);
});
it("creates and switches in one step — the default on Enter while dirty", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.type(await screen.findByLabelText("New branch name…"), "fix/ac-515{Enter}");
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
branch: "fix/ac-515",
switch: true,
})
);
});
it("switches branch on a clean tree", async () => {
@@ -0,0 +1,106 @@
/**
* @file LaneShelves.test.tsx
* @description Pins the Changes panel's Shelves list: hidden while there are
* none, one collapsed line otherwise, each shelf's branch, note and files,
* Restore sending `unshelve` by sha (and the conflict outcome told in a
* toast), Delete needing a second click before `shelf-drop` with
* `confirm: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneShelves } from "../LaneShelves";
import { api } from "../../../lib/api";
import { toast } from "../../../lib/toast";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({
api: { lanes: { gitAction: vi.fn(), gitShelves: vi.fn() } },
}));
const shelf = {
ref: "stash@{0}",
sha: "abc1234def5678",
branch: "fix/ac-515-a-rather-long-branch-name",
note: "half way through the login fix",
createdAt: "2026-11-02T03:04:05.000Z",
files: [
{ path: "src/login.ts", status: "M" },
{ path: "src/new.ts", status: "??" },
],
};
beforeEach(() => {
vi.mocked(api.lanes.gitShelves)
.mockReset()
.mockResolvedValue({
available: true,
shelves: [shelf],
});
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
action: "unshelve",
ok: true,
output: "",
conflicts: [],
});
});
describe("LaneShelves", () => {
it("renders nothing while there are no shelves", async () => {
vi.mocked(api.lanes.gitShelves).mockResolvedValue({ available: true, shelves: [] });
const { container } = render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await waitFor(() => expect(api.lanes.gitShelves).toHaveBeenCalledWith(3));
expect(container).toBeEmptyDOMElement();
});
it("lists a shelf with its branch, note and files", async () => {
const user = userEvent.setup();
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: /Shelves \(1\)/ }));
const row = screen.getByTestId("lane-shelf");
expect(row).toHaveTextContent(shelf.branch);
expect(row).toHaveTextContent(shelf.note);
await user.click(screen.getByRole("button", { name: "2 file(s)" }));
expect(screen.getByTestId("lane-shelf-files")).toHaveTextContent("src/new.ts");
});
it("restores by sha and reports a conflicted restore", async () => {
vi.mocked(api.lanes.gitAction).mockResolvedValueOnce({
action: "unshelve",
ok: true,
output: "",
conflicts: ["src/login.ts"],
kept: true,
});
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneShelves target={3} reread={0} onChanged={onChanged} />);
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
await user.click(screen.getByRole("button", { name: "Restore" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "unshelve", { sha: shelf.sha })
);
expect(onChanged).toHaveBeenCalled();
expect(toast.info).toHaveBeenCalledWith(expect.stringContaining("1 conflicted file"));
});
it("deletes only on the second click, with confirm", async () => {
const user = userEvent.setup();
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Delete for good?" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "shelf-drop", {
sha: shelf.sha,
confirm: true,
})
);
});
});
@@ -0,0 +1,156 @@
/**
* @file Blame / annotate at any revision (JetBrains' Annotate, GitLens'
* blame): the file's lines with, on the first line of each run, the commit
* that last changed it — short sha, author, relative date, summary. A commit
* opens its whole change; "Blame before this change" re-reads the file at
* that commit's parent (`previous`). Rows are windowed, so a 20 000-line
* file scrolls as fast as a short one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import type { BlameAt } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
import { GitModal } from "./GitModal";
const ROW = 18;
const OVERSCAN = 40;
export function BlameView({
target,
path,
rev,
onClose,
onOpenCommit,
}: {
target: GitTarget;
path: string;
/** A commit, or `work` for the file on disk. */
rev: string;
onClose: () => void;
onOpenCommit: (sha: string) => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [at, setAt] = useState({ path, rev });
const [blame, setBlame] = useState<BlameAt | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
setBlame(null);
setError(null);
void api.lanes.gitHistory
.blameAt(target, at.path, at.rev)
.then((r) => live && (r.available ? setBlame(r) : setError(t("noBlame"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
}, [at.path, at.rev]);
// Shade alternate commit runs so a run reads as one block.
const runs = useMemo(() => {
const starts: boolean[] = [];
const band: number[] = [];
let b = 0;
blame?.lines.forEach((l, i) => {
const start = i === 0 || blame.lines[i - 1]!.sha !== l.sha;
if (start && i) b++;
starts.push(start);
band.push(b);
});
return { starts, band };
}, [blame]);
const box = useRef<HTMLDivElement>(null);
const [top, setTop] = useState(0);
const height = 800;
const n = blame?.lines.length ?? 0;
const first = Math.max(0, Math.floor(top / ROW) - OVERSCAN);
const last = Math.min(n, Math.ceil((top + height) / ROW) + OVERSCAN);
return (
<GitModal
tall
testId="blame-view"
onClose={onClose}
title={
<span>
{t("blameTitle", { path: at.path })}{" "}
<span className="font-mono text-fg-muted">
@ {at.rev === "work" ? t("workingTree") : at.rev.slice(0, 7)}
</span>
</span>
}
>
{error && <p className="text-[12px] text-status-danger [overflow-wrap:anywhere]">{error}</p>}
{!blame && !error && (
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
)}
{blame && (
<div
ref={box}
onScroll={(e) => setTop(e.currentTarget.scrollTop)}
className="min-h-0 flex-1 overflow-auto font-mono text-[11px] leading-[18px]"
>
<div style={{ height: n * ROW, position: "relative", minWidth: "100%" }}>
{blame.lines.slice(first, last).map((l, k) => {
const i = first + k;
const c = l.sha ? blame.commits[l.sha] : null;
return (
<div
key={i}
data-testid="blame-line"
style={{ position: "absolute", top: i * ROW, height: ROW, left: 0, right: 0 }}
className={`flex ${runs.band[i]! % 2 ? "bg-surface-2/60" : ""}`}
>
<span className="flex w-[16rem] shrink-0 items-center gap-1 overflow-hidden border-r border-border-light pr-1 text-fg-muted">
{runs.starts[i] &&
(c && l.sha ? (
<>
<button
type="button"
onClick={() => onOpenCommit(l.sha!)}
title={`${c.summary ?? ""}\n${c.author ?? ""} · ${c.date ? new Date(c.date).toLocaleString() : ""}`}
className="shrink-0 text-accent hover:underline"
>
{c.short}
</button>
<span className="min-w-0 truncate font-sans" title={c.author}>
{c.author}
</span>
<span className="shrink-0 font-sans">
{c.date ? timeAgo(c.date, true) : ""}
</span>
{c.previous && (
<button
type="button"
title={t("blameBefore")}
aria-label={t("blameBefore")}
onClick={() => setAt({ path: at.path, rev: c.previous! })}
className="shrink-0 font-sans hover:text-fg-primary"
>
↶
</button>
)}
</>
) : (
<span className="font-sans italic">{t("notCommitted")}</span>
))}
</span>
<span className="w-10 shrink-0 select-none pr-1 text-right text-fg-muted">
{i + 1}
</span>
<span className="whitespace-pre pl-1 text-fg-primary">{l.text}</span>
</div>
);
})}
</div>
</div>
)}
</GitModal>
);
}
@@ -0,0 +1,641 @@
/**
* @file The git bar's Branches panel (JetBrains' branch popup, GitLens'
* Branches view): a dialog over the workspace listing every local branch
* (current marked, upstream with ahead/behind, last commit, why a delete would
* be refused) and the fetched remote branches grouped by remote, filtered by a
* search box and walked with the arrow keys; picking a row opens its actions —
* new branch from it (optionally switching to it on a clean tree), rename,
* set/unset upstream, merge into the current branch, delete. Its Tags tab is
* `TagManager`. Everything it may do is decided by the server
* (`GET …/git/refs`, `POST …/git/:action`, server/lib/git-refs.js); a disabled
* button here is a courtesy, and git's own text is shown when it refuses.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDown,
ArrowUp,
Check,
GitBranch,
GitMerge,
Link2,
Link2Off,
Pencil,
Plus,
Search,
Tag,
Trash2,
X,
} from "lucide-react";
import {
api,
gitTarget,
type GitLocalBranch,
type GitRefs,
type GitRemoteBranch,
type GitSource,
} from "../../../lib/api";
import { useDialog } from "../../../hooks/useDialog";
import { confirmDialog } from "../../../lib/confirm";
import { toast } from "../../../lib/toast";
import { isSubmitEnter } from "../../../lib/keys";
import { timeAgo } from "../../../lib/format";
import { Checkbox } from "../../Checkbox";
import { TagManager } from "./TagManager";
import { onListKeys, smallBtn, smallInput, type RunRefAction } from "./refUi";
type Mode = "new" | "rename" | "upstream" | null;
export function BranchManager({
laneId,
session,
source,
dirty,
merging,
onClose,
onChanged,
}: {
laneId: number;
session?: string;
source?: GitSource;
/** Uncommitted files: a "switch to it" is refused on a dirty tree. */
dirty: number;
/** A merge, cherry-pick or revert is open: every write here is refused. */
merging: boolean;
onClose: () => void;
/** Called after every write, so the git bar and the Changes panel re-read. */
onChanged: () => void;
}) {
const { t } = useTranslation(["gitRefs"]);
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
useDialog(true, panelRef, onClose, searchRef);
const target = gitTarget(laneId, session, source);
const [refs, setRefs] = useState<GitRefs | null>(null);
const [tab, setTab] = useState<"branches" | "tags">("branches");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>(null);
const [text, setText] = useState("");
const [switchTo, setSwitchTo] = useState(false);
const [upstream, setUpstream] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inFlight = useRef(false);
const load = useCallback(async () => {
try {
setRefs(await api.lanes.gitRefs(target));
} catch {
setRefs({
available: false,
current: null,
head: null,
local: [],
remotes: [],
tags: [],
truncated: false,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [laneId, session, source]);
useEffect(() => {
void load();
}, [load]);
const run: RunRefAction = async (action, body, done) => {
if (inFlight.current) return false;
inFlight.current = true;
setBusy(true);
setError(null);
try {
await api.lanes.gitRefAction(target, action, body);
if (done) toast.success(done);
setMode(null);
setText("");
await load();
onChanged();
return true;
} catch (err) {
const code = (err as { code?: string })?.code;
setError(
code === "EGITAUTH"
? t("errLogin")
: code === "ESUBDIR"
? t("errSubdir")
: err instanceof Error
? err.message
: String(err)
);
return false;
} finally {
inFlight.current = false;
setBusy(false);
}
};
const q = query.trim().toLowerCase();
const match = (s: string) => !q || s.toLowerCase().includes(q);
const local = useMemo(
() => (refs?.local || []).filter((b) => match(b.name)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[refs, q]
);
const remoteGroups = useMemo(
() =>
(refs?.remotes || [])
.map((g) => ({ ...g, branches: g.branches.filter((b) => match(b.name)) }))
.filter((g) => g.branches.length),
// eslint-disable-next-line react-hooks/exhaustive-deps
[refs, q]
);
const allRemote = useMemo(
() => (refs?.remotes || []).flatMap((g) => g.branches.map((b) => b.name)),
[refs]
);
const current = refs?.current ?? null;
const pick = (key: string) => {
setSelected((s) => (s === key ? null : key));
setMode(null);
setText("");
setError(null);
};
const open = (m: Mode, initial = "") => {
setMode((v) => (v === m ? null : m));
setText(initial);
setSwitchTo(false);
setError(null);
};
const merge = async (branch: string) => {
const ok = await confirmDialog({
title: t("merge"),
message: t("mergeConfirm", { branch, current: current ?? "HEAD" }),
confirmLabel: t("merge"),
destructive: false,
});
if (ok) await run("merge", { branch }, t("done.merge", { branch }));
};
const remove = async (b: GitLocalBranch) => {
const ok = await confirmDialog({
title: t("delete"),
message: b.merged
? t("deleteConfirm", { branch: b.name })
: t("deleteUnmerged", { branch: b.name }),
confirmLabel: t("delete"),
destructive: true,
});
if (ok) {
if (await run("branch-delete", { branch: b.name }, t("done.delete", { branch: b.name })))
setSelected(null);
}
};
/** Why `git branch -d` would refuse this branch, or null. */
const deleteBlock = (b: GitLocalBranch) =>
b.current
? t("why.current")
: b.protected
? t("why.protected")
: b.worktree
? t("why.worktree", { path: b.worktree })
: null;
const newBranchForm = (from: string) => (
<div className="flex flex-wrap items-center gap-1.5">
<input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && text.trim())
void run(
"branch-create",
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
t("done.create", { branch: text.trim() })
);
}}
placeholder={t("newBranchName")}
aria-label={t("newBranchName")}
className={smallInput}
/>
<Checkbox
checked={switchTo}
onChange={setSwitchTo}
disabled={dirty > 0 || merging}
label={t("switchToIt")}
/>
<button
type="button"
className={smallBtn}
disabled={busy || !text.trim()}
onClick={() =>
void run(
"branch-create",
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
t("done.create", { branch: text.trim() })
)
}
>
<Plus className="h-3 w-3" />
{t("create")}
</button>
{dirty > 0 && <span className="text-[10px] text-fg-muted">{t("switchDirty")}</span>}
</div>
);
const localActions = (b: GitLocalBranch) => {
const block = deleteBlock(b);
return (
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
<div className="flex flex-wrap gap-1">
<button type="button" className={smallBtn} disabled={busy} onClick={() => open("new")}>
<Plus className="h-3 w-3" />
{t("newFrom")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || b.protected || merging}
title={b.protected ? t("why.protected") : t("renameHint")}
onClick={() => open("rename", b.name)}
>
<Pencil className="h-3 w-3" />
{t("rename")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || merging || !allRemote.length}
title={allRemote.length ? t("upstreamHint") : t("noRemoteBranches")}
onClick={() => {
open("upstream");
setUpstream(b.upstream ?? "");
}}
>
<Link2 className="h-3 w-3" />
{t("setUpstream")}
</button>
{b.upstream && (
<button
type="button"
className={smallBtn}
disabled={busy || merging}
onClick={() =>
void run("branch-upstream", { branch: b.name }, t("done.unsetUpstream"))
}
>
<Link2Off className="h-3 w-3" />
{t("unsetUpstream")}
</button>
)}
{!b.current && (
<button
type="button"
className={smallBtn}
disabled={busy || merging || dirty > 0 || !current}
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
onClick={() => void merge(b.name)}
>
<GitMerge className="h-3 w-3" />
{t("mergeInto", { current: current ?? "HEAD" })}
</button>
)}
<button
type="button"
className={`${smallBtn} hover:text-status-danger`}
disabled={busy || !!block}
title={block ?? (b.merged ? t("deleteHint") : t("why.unmerged"))}
onClick={() => void remove(b)}
>
<Trash2 className="h-3 w-3" />
{t("delete")}
</button>
</div>
{block && <p className="text-[10px] text-fg-muted [overflow-wrap:anywhere]">{block}</p>}
{!block && !b.merged && (
<p className="text-[10px] text-status-warning">{t("why.unmerged")}</p>
)}
{mode === "new" && newBranchForm(b.name)}
{mode === "rename" && (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && text.trim() && text.trim() !== b.name)
void run(
"branch-rename",
{ branch: b.name, name: text.trim() },
t("done.rename", { branch: text.trim() })
);
}}
aria-label={t("renameTo")}
placeholder={t("renameTo")}
className={smallInput}
/>
<button
type="button"
className={smallBtn}
disabled={busy || !text.trim() || text.trim() === b.name}
onClick={() =>
void run(
"branch-rename",
{ branch: b.name, name: text.trim() },
t("done.rename", { branch: text.trim() })
)
}
>
<Check className="h-3 w-3" />
{t("rename")}
</button>
</div>
)}
{mode === "upstream" && (
<div className="flex items-center gap-1.5">
<select
value={upstream}
onChange={(e) => setUpstream(e.target.value)}
aria-label={t("setUpstream")}
className="select min-w-0 flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
>
<option value="">{t("pickUpstream")}</option>
{allRemote.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<button
type="button"
className={smallBtn}
disabled={busy || !upstream || upstream === b.upstream}
onClick={() =>
void run(
"branch-upstream",
{ branch: b.name, upstream },
t("done.setUpstream", { upstream })
)
}
>
<Check className="h-3 w-3" />
{t("apply")}
</button>
</div>
)}
</div>
);
};
const remoteActions = (r: GitRemoteBranch) => (
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
<div className="flex flex-wrap gap-1">
<button
type="button"
className={smallBtn}
disabled={busy}
onClick={() => open("new", r.branch)}
>
<Plus className="h-3 w-3" />
{t("newFrom")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || merging || dirty > 0 || !current}
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
onClick={() => void merge(r.name)}
>
<GitMerge className="h-3 w-3" />
{t("mergeInto", { current: current ?? "HEAD" })}
</button>
</div>
{mode === "new" && newBranchForm(r.name)}
</div>
);
const row = (
key: string,
name: string,
sub: React.ReactNode,
badges: React.ReactNode,
actions: () => React.ReactNode
) => (
<li key={key} className={selected === key ? "rounded bg-surface-2" : undefined}>
<button
type="button"
data-ref-row
aria-expanded={selected === key}
onClick={() => pick(key)}
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
>
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span title={name} className="truncate font-mono text-xs text-fg-primary">
{name}
</span>
{badges}
</span>
<span className="block truncate text-[10px] text-fg-muted">{sub}</span>
</span>
</button>
{selected === key && actions()}
</li>
);
const badge = (label: string, tone = "text-fg-muted border-border-light") => (
<span className={`shrink-0 rounded border px-1 text-[9px] uppercase ${tone}`}>{label}</span>
);
const localRow = (b: GitLocalBranch) =>
row(
`l:${b.name}`,
b.name,
<>
{b.subject}
{b.date ? ` · ${timeAgo(b.date)}` : ""}
</>,
<>
{b.current && badge(t("current"), "text-accent border-accent/40")}
{b.protected && badge(t("protected"))}
{b.worktree && (
<span title={b.worktree} className="shrink-0">
{badge(t("worktree"))}
</span>
)}
{b.upstream && (
<span
title={b.upstream}
className="flex shrink-0 items-center gap-0.5 font-mono text-[10px] text-fg-muted"
>
{b.gone ? (
<span className="text-status-warning">{t("gone")}</span>
) : (
<>
{(b.ahead ?? 0) > 0 && (
<span className="flex items-center text-status-warning">
<ArrowUp className="h-3 w-3" aria-label={t("ahead")} />
{b.ahead}
</span>
)}
{(b.behind ?? 0) > 0 && (
<span className="flex items-center text-accent">
<ArrowDown className="h-3 w-3" aria-label={t("behind")} />
{b.behind}
</span>
)}
{!b.ahead && !b.behind && <Check className="h-3 w-3" aria-label={t("inSync")} />}
</>
)}
</span>
)}
</>,
() => localActions(b)
);
const remoteRow = (r: GitRemoteBranch) =>
row(
`r:${r.name}`,
r.name,
<>
{r.subject}
{r.date ? ` · ${timeAgo(r.date)}` : ""}
</>,
null,
() => remoteActions(r)
);
const tabBtn = (id: "branches" | "tags", label: string, icon: React.ReactNode) => (
<button
type="button"
role="tab"
aria-selected={tab === id}
onClick={() => {
setTab(id);
setSelected(null);
setMode(null);
setError(null);
}}
className={`flex items-center gap-1 border-b-2 px-2 py-1 text-xs ${
tab === id
? "border-accent text-fg-primary"
: "border-transparent text-fg-muted hover:text-fg-primary"
}`}
>
{icon}
{label}
</button>
);
return (
<div
className="fixed inset-0 z-[55] flex items-start justify-center bg-black/60 p-4 sm:pt-16"
onClick={onClose}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid="branch-manager"
onClick={(e) => e.stopPropagation()}
className="flex max-h-full w-full max-w-xl min-w-0 flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
>
<div className="flex items-center gap-2 border-b border-border-light px-3 pt-2">
<h3 id={titleId} className="sr-only">
{t("title")}
</h3>
<div role="tablist" className="flex min-w-0 flex-1 gap-1">
{tabBtn("branches", t("branches"), <GitBranch className="h-3.5 w-3.5" />)}
{tabBtn("tags", t("tags"), <Tag className="h-3.5 w-3.5" />)}
</div>
<button
type="button"
className="btn-icon -mt-1"
aria-label={t("close")}
onClick={onClose}
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div className="flex items-center gap-1.5 px-3 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<input
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
panelRef.current?.querySelector<HTMLElement>("[data-ref-row]")?.focus();
}
}}
placeholder={tab === "tags" ? t("searchTags") : t("searchBranches")}
aria-label={tab === "tags" ? t("searchTags") : t("searchBranches")}
className={`${smallInput} !text-xs`}
/>
</div>
{merging && <p className="px-3 pb-1 text-[11px] text-status-warning">{t("merging")}</p>}
{error && (
<pre
role="alert"
data-testid="branch-manager-error"
className="mx-3 mb-2 max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger [overflow-wrap:anywhere]"
>
{error}
</pre>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3" onKeyDown={onListKeys}>
{!refs ? (
<p className="px-2 py-4 text-xs text-fg-muted">{t("loading")}</p>
) : !refs.available ? (
<p className="px-2 py-4 text-xs text-fg-muted">{t("unavailable")}</p>
) : tab === "tags" ? (
<TagManager
tags={(refs.tags || []).filter((x) => match(x.name))}
busy={busy}
merging={merging}
dirty={dirty}
run={run}
/>
) : (
<>
<h4 className="px-2 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
{t("local")}
</h4>
{local.length ? (
<ul>{local.map(localRow)}</ul>
) : (
<p className="px-2 text-xs text-fg-muted">{t("noMatch")}</p>
)}
{remoteGroups.map((g) => (
<section key={g.remote}>
<h4
title={g.remote}
className="truncate px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-fg-muted"
>
{t("remote", { remote: g.remote })}
</h4>
<ul>{g.branches.map(remoteRow)}</ul>
</section>
))}
{refs.truncated && (
<p className="px-2 pt-2 text-[10px] text-fg-muted">{t("truncated")}</p>
)}
</>
)}
</div>
</div>
</div>
);
}
export default BranchManager;
@@ -0,0 +1,71 @@
/**
* @file The git bar's "Branches & tags" icon button, which owns the Branches
* panel (`BranchManager`) it opens — portalled to `document.body`, so no
* scrolling or transformed ancestor of the Changes panel can clip it. The
* panel closes when the bar starts showing another workspace.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Tags } from "lucide-react";
import type { GitSource } from "../../../lib/api";
import { BranchManager } from "./BranchManager";
export function BranchesButton({
laneId,
session,
source,
dirty,
merging,
disabled,
onChanged,
}: {
laneId: number;
session?: string;
source?: GitSource;
dirty: number;
merging: boolean;
disabled?: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitRefs"]);
const [open, setOpen] = useState(false);
const [shown, setShown] = useState(`${laneId}|${session ?? ""}|${source ?? ""}`);
const key = `${laneId}|${session ?? ""}|${source ?? ""}`;
if (shown !== key) {
setShown(key);
setOpen(false);
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
disabled={disabled}
title={t("openPanelHint")}
aria-label={t("openPanel")}
aria-haspopup="dialog"
className="flex h-6 w-6 items-center justify-center rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Tags className="h-3.5 w-3.5" />
</button>
{open &&
createPortal(
<BranchManager
laneId={laneId}
session={session}
source={source}
dirty={dirty}
merging={merging}
onClose={() => setOpen(false)}
onChanged={onChanged}
/>,
document.body
)}
</>
);
}
export default BranchesButton;
@@ -0,0 +1,175 @@
/**
* @file Browse files at a revision (GitHub's "Browse files", GitLens' "Open
* files at revision"): a read-only tree of the workspace folder as it was at
* one commit — folders open on a click, read lazily — and the picked file's
* text (or image) beside it, with Blame at that commit. Every read goes
* through the server's own guards: a secret file is neither listed nor
* readable, a big file is capped, a binary one is named, not shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, File, Folder } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { TreeAtEntry } from "../../../lib/gitHistory";
import type { LaneFileContent } from "../../../lib/types";
import { LaneFileView } from "../LaneDiffView";
import { GitModal } from "./GitModal";
import { BlameView } from "./BlameView";
const IMAGE = /\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i;
export function BrowseAtRevision({
target,
sha,
short,
onClose,
}: {
target: GitTarget;
sha: string;
short: string;
onClose: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
// Folder path → its entries; "" is the workspace folder.
const [tree, setTree] = useState<Record<string, TreeAtEntry[]>>({});
const [open, setOpen] = useState<Set<string>>(new Set([""]));
const [file, setFile] = useState<string | null>(null);
const [content, setContent] = useState<LaneFileContent | null>(null);
const [error, setError] = useState<string | null>(null);
const [blame, setBlame] = useState(false);
const readDir = (dir: string) => {
if (tree[dir]) return;
void api.lanes.gitHistory
.treeAt(target, sha, dir)
.then((r) => r.available && setTree((m) => ({ ...m, [dir]: r.entries })))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
useEffect(() => {
readDir("");
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per commit
}, [sha]);
const openFile = (p: string) => {
setFile(p);
setContent(null);
setError(null);
if (IMAGE.test(p)) return;
void api.lanes
.gitFile(target, p, sha)
.then(setContent)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
const toggleDir = (p: string) => {
setOpen((s) => {
const next = new Set(s);
if (next.has(p)) next.delete(p);
else next.add(p);
return next;
});
readDir(p);
};
const rows = (dir: string, depth: number): ReactNode =>
(tree[dir] ?? []).map((e) => (
<li key={e.path}>
<button
type="button"
onClick={() => (e.dir ? toggleDir(e.path) : e.submodule ? undefined : openFile(e.path))}
title={e.path}
style={{ paddingLeft: depth * 12 + 4 }}
className={`flex w-full items-center gap-1 rounded py-0.5 pr-1 text-left text-[11px] hover:bg-surface-2 ${
file === e.path ? "bg-surface-3 text-fg-primary" : "text-fg-secondary"
}`}
>
{e.dir ? (
open.has(e.path) ? (
<ChevronDown className="h-3 w-3 shrink-0" aria-hidden />
) : (
<ChevronRight className="h-3 w-3 shrink-0" aria-hidden />
)
) : (
<span className="w-3 shrink-0" />
)}
{e.dir ? (
<Folder className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
) : (
<File className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
)}
<span className="min-w-0 truncate">{e.name}</span>
</button>
{e.dir && open.has(e.path) && <ul>{rows(e.path, depth + 1)}</ul>}
</li>
));
return (
<GitModal
tall
testId="browse-at-revision"
onClose={onClose}
title={t("browseTitle", { short })}
>
<div className="flex min-h-0 flex-1 flex-col gap-2 sm:flex-row">
<ul
data-testid="browse-tree"
className="max-h-[35vh] shrink-0 overflow-auto rounded border border-border-light p-1 sm:max-h-none sm:w-64"
>
{tree[""] ? (
rows("", 0)
) : (
<li className="text-[11px] text-fg-muted">{t("lanes:changes.loading")}</li>
)}
</ul>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{file && (
<div className="mb-1 flex items-center gap-1.5 text-[11px]">
<span className="min-w-0 flex-1 font-mono text-fg-primary [overflow-wrap:anywhere]">
{file}
</span>
{!IMAGE.test(file) && (
<button
type="button"
onClick={() => setBlame(true)}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2"
>
{t("blame")}
</button>
)}
</div>
)}
{error && (
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
{!file && !error && <p className="text-[12px] text-fg-muted">{t("pickAFile")}</p>}
{file && IMAGE.test(file) && (
<img
src={api.lanes.gitRawUrl(target, file, sha)}
alt={file}
className="max-h-full max-w-full self-start object-contain"
/>
)}
{file && content && content.available && content.binary && (
<p className="text-[12px] text-fg-muted">{t("binaryFile")}</p>
)}
{file && content && content.available && !content.binary && (
<LaneFileView path={file} lines={content.lines} truncated={content.truncated} fill />
)}
</div>
</div>
{blame && file && (
<BlameView
target={target}
path={file}
rev={sha}
onClose={() => setBlame(false)}
onOpenCommit={() => setBlame(false)}
/>
)}
</GitModal>
);
}
@@ -0,0 +1,152 @@
/**
* @file The confirmation a cherry-pick or revert opens with, showing exactly
* what will happen before anything does (the reference tools' preview): the
* commits in the order git will apply them (numbered — read from the server,
* which orders them the same way the action will), the branch they land on,
* whether the working tree is clean, and what happens on a conflict. Confirm
* is disabled, with the reason, when the server would refuse (a dirty tree, a
* subfolder workspace, another operation open, a merge commit).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, AlertTriangle } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { PickPlan, PickResult } from "../../../lib/gitHistory";
import { GitModal, ModalButton } from "./GitModal";
export function CherryPickDialog({
target,
op,
shas,
onClose,
onDone,
}: {
target: GitTarget;
op: "cherry-pick" | "revert";
shas: string[];
onClose: () => void;
onDone: (r: PickResult) => void;
}) {
const { t } = useTranslation(["gitHistory"]);
const [plan, setPlan] = useState<PickPlan | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
useEffect(() => {
let live = true;
void api.lanes.gitHistory
.pickPlan(target, op, shas)
.then((r) => live && (r.available ? setPlan(r) : setError(t("planUnavailable"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- one plan per dialog
}, []);
const branch = plan?.branch ?? "HEAD";
const blocker = !plan
? null
: plan.busy
? t("blockBusy")
: !plan.repoRoot
? t("blockSubdir")
: !plan.clean
? t("blockDirty")
: null;
const n = plan?.commits.length ?? shas.length;
const title =
op === "revert" ? t("revertTitle", { count: n }) : t("pickTitle", { count: n, branch });
const run = async () => {
setRunning(true);
setError(null);
try {
onDone(
await api.lanes.gitHistory.pick(
target,
op,
plan!.commits.map((c) => c.sha)
)
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setRunning(false);
}
};
return (
<GitModal
title={title}
onClose={onClose}
testId="pick-dialog"
footer={
<>
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
<ModalButton
primary
testId="pick-confirm"
disabled={!plan || !!blocker || running}
title={blocker ?? undefined}
onClick={() => void run()}
>
{running
? t("running")
: op === "revert"
? t("revertN", { count: n })
: t("pickN", { count: n, branch })}
</ModalButton>
</>
}
>
<div className="space-y-2 text-[12px] text-fg-secondary">
{!plan && !error && <p className="text-fg-muted">{t("planning")}</p>}
{plan && (
<>
<p className="[overflow-wrap:anywhere]">
{op === "revert"
? t("revertIntro", { count: n, branch })
: t("pickIntro", { count: n, branch })}
</p>
<ol
data-testid="pick-plan"
className="space-y-0.5 rounded border border-border-light bg-surface-0 p-1.5"
>
{plan.commits.map((c, i) => (
<li key={c.sha} className="flex min-w-0 items-baseline gap-1.5">
<span className="w-5 shrink-0 text-right text-fg-muted">{i + 1}.</span>
<span className="shrink-0 font-mono text-[11px] text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
{c.subject}
</span>
</li>
))}
</ol>
<p className="flex items-center gap-1.5">
{plan.clean && plan.repoRoot && !plan.busy ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-status-success" aria-hidden />
) : (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-status-warning" aria-hidden />
)}
<span data-testid="pick-tree-state" className="[overflow-wrap:anywhere]">
{blocker ?? t("treeClean")}
</span>
</p>
<p className="text-fg-muted">{t(op === "revert" ? "revertResult" : "pickResult")}</p>
<p className="text-fg-muted">{t("onConflict", { op: t(`op.${op}`) })}</p>
</>
)}
{error && (
<p
role="alert"
className="whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
>
{error}
</p>
)}
</div>
</GitModal>
);
}
@@ -0,0 +1,203 @@
/**
* @file The History tab's commit details pane (GitKraken's / JetBrains'
* right-hand commit panel): the full message, author and committer with
* dates, parents (each opens that commit), the commit's refs and the branches
* and tags that contain it, its changed files with +/- counts (a file opens
* that commit's diff of it in the panel's own diff viewer), and Copy SHA,
* Copy patch, Browse files, Compare with current and View all changes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { CommitDetails as Details } from "../../../lib/gitHistory";
import type { LaneChangeFile } from "../../../lib/types";
import { copyText } from "../../../lib/clipboard";
import { RefChip, sortBadges } from "./CommitGraph";
export function CommitDetails({
target,
sha,
onClose,
onOpenCommit,
onViewChanges,
onOpenFile,
onBrowse,
onCompare,
onCopyPatch,
}: {
target: GitTarget;
sha: string;
onClose: () => void;
/** A parent clicked: focus that commit. */
onOpenCommit: (sha: string) => void;
onViewChanges: () => void;
onOpenFile: (path: string) => void;
onBrowse: () => void;
onCompare: () => void;
onCopyPatch: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [details, setDetails] = useState<Details | null>(null);
const [files, setFiles] = useState<LaneChangeFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
void api.lanes.gitHistory
.commit(target, sha)
.then((r) => live && (r.available ? setDetails(r) : setError(t("noDetails"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
void api.lanes
.changes(target, { range: "commit", sha })
.then((r) => live && setFiles(r.available ? r.files : []))
.catch(() => live && setFiles([]));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on sha by the parent
}, [sha]);
const button =
"shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary";
const added = files?.reduce((n, f) => n + (f.added ?? 0), 0) ?? 0;
const deleted = files?.reduce((n, f) => n + (f.deleted ?? 0), 0) ?? 0;
return (
<section
data-testid="commit-details"
aria-label={t("detailsTitle")}
className="max-h-[45%] min-h-[6rem] shrink-0 overflow-y-auto rounded border border-border-light bg-surface-0 p-2 text-[11px]"
>
<div className="mb-1 flex items-start gap-1.5">
<span className="font-mono text-fg-muted">{sha.slice(0, 10)}</span>
<span className="min-w-0 flex-1" />
<button
type="button"
aria-label={t("close")}
onClick={onClose}
className="shrink-0 text-fg-muted hover:text-fg-primary"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{error && <p className="text-status-danger [overflow-wrap:anywhere]">{error}</p>}
{!details && !error && <p className="text-fg-muted">{t("lanes:changes.loading")}</p>}
{details && (
<>
<p
data-testid="commit-details-message"
className="mb-1.5 whitespace-pre-wrap text-fg-primary [overflow-wrap:anywhere]"
>
{details.message}
</p>
<dl className="mb-1.5 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-fg-secondary">
<dt className="text-fg-muted">{t("author")}</dt>
<dd className="min-w-0 [overflow-wrap:anywhere]">
{details.author} &lt;{details.email}&gt; · {new Date(details.date).toLocaleString()}
</dd>
{(details.committer !== details.author || details.committedAt !== details.date) && (
<>
<dt className="text-fg-muted">{t("committer")}</dt>
<dd className="min-w-0 [overflow-wrap:anywhere]">
{details.committer} · {new Date(details.committedAt).toLocaleString()}
</dd>
</>
)}
<dt className="text-fg-muted">{t("parents", { count: details.parents.length })}</dt>
<dd className="flex min-w-0 flex-wrap gap-1">
{details.parents.length === 0 && <span>{t("rootCommit")}</span>}
{details.parents.map((p) => (
<button
key={p}
type="button"
onClick={() => onOpenCommit(p)}
className="font-mono text-accent hover:underline"
>
{p.slice(0, 7)}
</button>
))}
</dd>
{details.badges.length > 0 && (
<>
<dt className="text-fg-muted">{t("refs")}</dt>
<dd className="flex min-w-0 flex-wrap gap-1">
{sortBadges(details.badges).map((b) => (
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
))}
</dd>
</>
)}
{(details.branches.length > 0 || details.tags.length > 0) && (
<>
<dt className="text-fg-muted">{t("containedIn")}</dt>
<dd className="min-w-0 [overflow-wrap:anywhere]">
{[...details.branches, ...details.tags.map((x) => `tag ${x}`)].join(", ")}
{details.containsTruncated ? " …" : ""}
</dd>
</>
)}
</dl>
<div className="mb-1.5 flex flex-wrap gap-1">
<button type="button" className={button} onClick={onViewChanges}>
{t("viewAllChanges")}
</button>
<button type="button" className={button} onClick={() => void copyText(details.sha)}>
{t("lanes:changes.copySha")}
</button>
<button
type="button"
className={button}
disabled={details.parents.length > 1}
onClick={onCopyPatch}
>
{t("copyPatch")}
</button>
<button type="button" className={button} onClick={onBrowse}>
{t("browseFiles")}
</button>
<button type="button" className={button} onClick={onCompare}>
{t("compareWithCurrent")}
</button>
</div>
</>
)}
{files && (
<>
<p className="mb-0.5 text-fg-muted">
{t("filesChanged", { count: files.length })}{" "}
<span className="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span>
</p>
<ul data-testid="commit-details-files">
{files.map((f) => (
<li key={f.path}>
<button
type="button"
onClick={() => onOpenFile(f.path)}
title={f.path}
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left hover:bg-surface-2"
>
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
{f.path}
</span>
{f.binary ? (
<span className="shrink-0 text-fg-muted">{t("binary")}</span>
) : (
<span className="shrink-0 font-mono">
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
<span className="text-status-danger">−{f.deleted ?? 0}</span>
</span>
)}
</button>
</li>
))}
</ul>
</>
)}
</section>
);
}
@@ -0,0 +1,182 @@
/**
* @file The History tab's commit rows, drawn like VS Code's Source Control
* Graph / GitLens: the graph cell (lanes and merge edges from
* `lib/commitGraph`), the ref badges (current branch, local and remote
* branches, tags, a detached HEAD), the subject, author and relative date.
* Colours are theme tokens (CSS variables), so a lane reads in every theme.
* Long subjects and names truncate with the whole text in a tooltip.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { Cloud, GitBranch, Tag, Crosshair } from "lucide-react";
import type { GraphRow } from "../../../lib/commitGraph";
import type { RefBadge } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
/** One fixed-height row per commit so the lanes join up row to row. */
export const GRAPH_ROW = 30;
const GRAPH_COL = 12;
/** Columns drawn at most; a wider graph is clipped, not the row. */
const MAX_COLS = 12;
/** Theme tokens, cycled per lane. */
const GRAPH_COLORS = [
"rgb(var(--accent))",
"rgb(var(--status-success))",
"rgb(var(--status-warning))",
"rgb(var(--syn-property))",
"rgb(var(--status-danger))",
"rgb(var(--syn-function))",
"rgb(var(--kept))",
"rgb(var(--diff-mod))",
];
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
export function GraphCell({
row,
width,
merge,
head,
}: {
row: GraphRow;
width: number;
/** A merge commit: a hollow node. */
merge?: boolean;
/** HEAD: a ringed node. */
head?: boolean;
}) {
const cols = Math.min(width, MAX_COLS);
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
const y = (f: number) => f * GRAPH_ROW;
return (
<svg
aria-hidden
width={cols * GRAPH_COL}
height={GRAPH_ROW}
className="shrink-0 overflow-hidden"
data-testid="lane-changes-graph"
>
{row.segs.map((g, i) => {
const ym = (y(g.y1) + y(g.y2)) / 2;
return (
<path
key={i}
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
fill="none"
stroke={hue(g.color)}
strokeWidth={1.5}
/>
);
})}
{head && (
<circle
cx={x(row.col)}
cy={y(0.5)}
r={6}
fill="none"
stroke={hue(row.color)}
strokeWidth={1.5}
/>
)}
<circle
cx={x(row.col)}
cy={y(0.5)}
r={3.5}
fill={merge ? "rgb(var(--surface-1))" : hue(row.color)}
stroke={hue(row.color)}
strokeWidth={merge ? 1.5 : 0}
/>
</svg>
);
}
/** One ref badge: the kind's icon, the name truncated, the full name on hover. */
export function RefChip({ badge }: { badge: RefBadge }) {
const { t } = useTranslation(["gitHistory"]);
const Icon =
badge.kind === "tag"
? Tag
: badge.kind === "remote"
? Cloud
: badge.kind === "head"
? Crosshair
: GitBranch;
const tone = badge.current
? "border-accent/60 bg-accent/15 text-accent font-semibold"
: badge.kind === "tag"
? "border-status-warning/50 text-status-warning"
: badge.kind === "remote"
? "border-border text-fg-muted"
: badge.kind === "head"
? "border-status-danger/50 text-status-danger"
: "border-status-success/50 text-status-success";
const label = t(`badge.${badge.kind}`, { name: badge.name });
return (
<span
title={badge.current ? t("badge.current", { name: badge.name }) : label}
data-testid="ref-badge"
data-kind={badge.kind}
className={`inline-flex max-w-[10rem] shrink items-center gap-0.5 rounded border px-1 text-[10px] leading-4 ${tone}`}
>
<Icon className="h-2.5 w-2.5 shrink-0" aria-hidden />
<span className="truncate">{badge.name}</span>
</span>
);
}
/** Badges in the order the reference tools show them: HEAD's branch first. */
export function sortBadges(badges: RefBadge[]): RefBadge[] {
const rank = (b: RefBadge) =>
b.current ? 0 : b.kind === "head" ? 1 : b.kind === "local" ? 2 : b.kind === "remote" ? 3 : 4;
return [...badges].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
}
export interface GraphCommit {
sha: string;
short: string;
subject: string;
author: string;
email?: string;
date: string;
parents: string[];
badges?: RefBadge[];
}
/** The text half of a history row (the graph cell is drawn beside it). */
export const CommitText = memo(function CommitText({ c }: { c: GraphCommit }) {
const badges = sortBadges(c.badges ?? []);
const when = new Date(c.date);
return (
<span className="flex min-w-0 flex-1 flex-col justify-center">
<span className="flex min-w-0 items-center gap-1">
{badges.slice(0, 4).map((b) => (
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
))}
{badges.length > 4 && (
<span
className="shrink-0 text-[10px] text-fg-muted"
title={badges
.slice(4)
.map((b) => b.name)
.join(", ")}
>
+{badges.length - 4}
</span>
)}
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary" title={c.subject}>
{c.subject}
</span>
</span>
<span className="flex min-w-0 items-baseline gap-1.5 text-[10px] text-fg-muted">
<span className="shrink-0 font-mono">{c.short}</span>
<span className="min-w-0 truncate" title={c.email ? `${c.author} <${c.email}>` : c.author}>
{c.author}
</span>
<span className="shrink-0" title={when.toLocaleString()}>
{timeAgo(c.date)}
</span>
</span>
</span>
);
});
@@ -0,0 +1,245 @@
/**
* @file Compare any two refs (GitLens' Search & Compare, GitHub's compare
* view): branches, tags or commits A and B; the commits only in A and only in
* B, and the files that differ — "Changes since they diverged" (A...B, what B
* adds; GitHub's default) or "Difference between the two" (A B, the trees as
* they stand). A file opens its diff in the panel's diff viewer; a commit
* opens its whole change. Swap flips the two sides.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeftRight } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { CompareResult, CompareFile } from "../../../lib/gitHistory";
import { LaneDiffView } from "../LaneDiffView";
import { GitModal } from "./GitModal";
type Tab = "files" | "onlyA" | "onlyB";
export function CompareRefs({
target,
initialA,
initialB,
refs,
onClose,
onOpenCommit,
}: {
target: GitTarget;
initialA: string;
initialB: string;
/** Branch names offered in the inputs' suggestion lists. */
refs: string[];
onClose: () => void;
onOpenCommit: (sha: string) => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [a, setA] = useState(initialA);
const [b, setB] = useState(initialB);
const [asked, setAsked] = useState({ a: initialA, b: initialB });
const [mode, setMode] = useState<"three" | "two">("three");
const [result, setResult] = useState<CompareResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("files");
const [file, setFile] = useState<CompareFile["file"] | null>(null);
const [fileLoading, setFileLoading] = useState(false);
useEffect(() => {
let live = true;
setResult(null);
setError(null);
setFile(null);
void api.lanes.gitHistory
.compare(target, asked.a, asked.b, mode)
.then((r) => live && (r.available ? setResult(r) : setError(t("compareUnavailable"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
}, [asked, mode]);
const openFile = (path: string, from?: string) => {
if (!result) return;
setFileLoading(true);
void api.lanes.gitHistory
.compareFile(target, result.a, result.b, mode, path, from)
.then((r) => r.available && setFile(r.file))
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setFileLoading(false));
};
const input =
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5 font-mono text-[11px] text-fg-primary";
const tabBtn = (k: Tab, label: string) => (
<button
key={k}
type="button"
aria-pressed={tab === k}
onClick={() => {
setTab(k);
setFile(null);
}}
className={`rounded px-2 py-0.5 text-[11px] ${
tab === k ? "bg-accent-solid text-white" : "text-fg-secondary hover:bg-surface-3"
}`}
>
{label}
</button>
);
const commits = tab === "onlyA" ? result?.onlyA : result?.onlyB;
return (
<GitModal tall testId="compare-refs" onClose={onClose} title={t("compareTitle")}>
<form
className="mb-2 flex flex-wrap items-center gap-1.5"
onSubmit={(e) => {
e.preventDefault();
if (a.trim() && b.trim()) setAsked({ a: a.trim(), b: b.trim() });
}}
>
<datalist id="compare-refs-list">
{refs.map((r) => (
<option key={r} value={r} />
))}
</datalist>
<input
aria-label={t("compareA")}
data-testid="compare-a"
list="compare-refs-list"
value={a}
maxLength={200}
onChange={(e) => setA(e.target.value)}
className={input}
/>
<button
type="button"
aria-label={t("swap")}
title={t("swap")}
onClick={() => {
setA(b);
setB(a);
setAsked({ a: asked.b, b: asked.a });
}}
className="shrink-0 rounded p-1 text-fg-secondary hover:bg-surface-2"
>
<ArrowLeftRight className="h-3.5 w-3.5" />
</button>
<input
aria-label={t("compareB")}
data-testid="compare-b"
list="compare-refs-list"
value={b}
maxLength={200}
onChange={(e) => setB(e.target.value)}
className={input}
/>
<button
type="submit"
className="shrink-0 rounded bg-accent-solid px-2 py-0.5 text-[11px] text-white hover:bg-accent-solid-hover"
>
{t("compare")}
</button>
<select
data-testid="compare-mode"
value={mode}
onChange={(e) => setMode(e.target.value === "two" ? "two" : "three")}
className="min-w-0 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
>
<option value="three">{t("modeThree")}</option>
<option value="two">{t("modeTwo")}</option>
</select>
</form>
{error && (
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
{!result && !error && (
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
)}
{result && (
<>
<div className="mb-1.5 flex flex-wrap gap-1 rounded border border-border bg-surface-2 p-0.5">
{tabBtn("files", t("tabFiles", { count: result.files.length }))}
{tabBtn("onlyA", t("tabOnly", { name: asked.a, count: result.onlyA.length }))}
{tabBtn("onlyB", t("tabOnly", { name: asked.b, count: result.onlyB.length }))}
</div>
{tab === "files" && !file && (
<ul data-testid="compare-files" className="min-h-0 flex-1 overflow-auto">
{result.files.length === 0 && (
<li className="text-[12px] text-fg-muted">{t("noDifference")}</li>
)}
{result.files.map((f) => (
<li key={f.path}>
<button
type="button"
disabled={fileLoading}
onClick={() => openFile(f.path, f.from)}
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
>
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
{f.from ? `${f.from} → ${f.path}` : f.path}
</span>
<span className="shrink-0 font-mono">
<span className="text-status-success">+{f.added}</span>{" "}
<span className="text-status-danger">−{f.deleted}</span>
</span>
</button>
</li>
))}
{result.filesTruncated && (
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
)}
</ul>
)}
{tab === "files" && file && (
<div className="flex min-h-0 flex-1 flex-col">
<button
type="button"
onClick={() => setFile(null)}
className="mb-1 self-start rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
>
{t("backToFiles")}
</button>
{file.secret ? (
<p className="text-[12px] text-fg-muted">{t("secretFile")}</p>
) : file.hunks?.length ? (
<LaneDiffView file={file} layout="unified" fill />
) : (
<p className="text-[12px] text-fg-muted">{t("noTextChange")}</p>
)}
</div>
)}
{tab !== "files" && (
<ul className="min-h-0 flex-1 overflow-auto">
{commits?.length === 0 && (
<li className="text-[12px] text-fg-muted">{t("noCommitsSide")}</li>
)}
{commits?.map((c) => (
<li key={c.sha}>
<button
type="button"
onClick={() => onOpenCommit(c.sha)}
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
>
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
{c.subject}
</span>
<span className="min-w-0 max-w-[30%] truncate text-fg-muted">{c.author}</span>
</button>
</li>
))}
{result.commitsTruncated && (
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
)}
</ul>
)}
</>
)}
</GitModal>
);
}
@@ -0,0 +1,128 @@
/**
* @file "Create branch here…" and "Create tag here…" from a History commit's
* menu: a name (and, for a tag, an optional message making it annotated),
* then git-refs.js's `branch-create {branch, from}` / `tag-create {tag,
* message?, from}` with that commit's sha as `from` — the server confirms the
* sha itself. Creating a branch never switches to it here. Announces
* `agenthub:git-changed` so the other views of the workspace read again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import { toast } from "../../../lib/toast";
import { isSubmitEnter } from "../../../lib/keys";
import { emitGitChanged } from "../../../lib/gitChanged";
import { GitModal, ModalButton } from "./GitModal";
export function CreateBranchDialog({
target,
sha,
short,
kind = "branch",
onClose,
onDone,
}: {
target: GitTarget;
sha: string;
short: string;
kind?: "branch" | "tag";
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation(["gitHistory"]);
const [name, setName] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const tag = kind === "tag";
const run = async () => {
const ref = name.trim();
if (!ref) return;
setRunning(true);
setError(null);
const action = tag ? "tag-create" : "branch-create";
try {
await api.lanes.gitRefAction(
target,
action,
tag
? { tag: ref, from: sha, ...(message.trim() ? { message: message.trim() } : {}) }
: { branch: ref, from: sha }
);
toast.success(t(tag ? "tagCreated" : "branchCreated", { name: ref, branch: ref, short }));
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setRunning(false);
} finally {
emitGitChanged(typeof target === "number" ? target : target.lane, action);
}
};
const submit = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void run();
}
};
const input =
"rounded border border-border-light bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-primary";
return (
<GitModal
testId="create-ref"
onClose={onClose}
title={t(tag ? "createTagTitle" : "createBranchTitle", { short })}
footer={
<>
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
<ModalButton
primary
testId="create-ref-confirm"
disabled={!name.trim() || running}
onClick={() => void run()}
>
{t(tag ? "createTag" : "createBranch")}
</ModalButton>
</>
}
>
<label className="flex flex-col gap-1 text-[12px] text-fg-secondary">
{t(tag ? "tagName" : "branchName")}
<input
autoFocus
data-testid="create-ref-name"
value={name}
maxLength={200}
onChange={(e) => setName(e.target.value)}
onKeyDown={submit}
className={input}
/>
</label>
{tag && (
<label className="mt-2 flex flex-col gap-1 text-[12px] text-fg-secondary">
{t("tagMessage")}
<input
data-testid="create-ref-message"
value={message}
maxLength={1000}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={submit}
className={input}
/>
</label>
)}
<p className="mt-1.5 text-[11px] text-fg-muted">
{t(tag ? "createTagHint" : "createBranchHint")}
</p>
{error && (
<p role="alert" className="mt-1.5 text-[12px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
</GitModal>
);
}
@@ -0,0 +1,194 @@
/**
* @file One file's history (Show History), like GitLens' File History: every
* commit that touched it, newest first, followed across renames (`git log
* --follow`). A version opens that commit's diff of the file under the name
* it had there (the panel's own diff viewer); Blame opens the file as it was
* at that version with each line's commit (BlameView), and a rename says
* where it came from. Paged with Load more.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import type { FileVersion } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
import { BlameView } from "./BlameView";
const PAGE = 100;
export function FileHistory({
target,
path,
reread,
onOpenVersion,
onViewCommit,
onWholeBranch,
}: {
target: GitTarget;
path: string;
reread: number;
/** That version's diff of the file: its name THEN, and the commit. */
onOpenVersion: (path: string, sha: string) => void;
onViewCommit: (sha: string) => void;
onWholeBranch: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [versions, setVersions] = useState<FileVersion[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [blame, setBlame] = useState<{ path: string; rev: string } | null>(null);
const seq = useRef(0);
useEffect(() => {
const mine = ++seq.current;
setVersions(null);
setError(null);
setLoading(true);
void api.lanes.gitHistory
.fileHistory(target, path, { limit: PAGE })
.then((r) => {
if (seq.current !== mine) return;
setVersions(r.available ? r.commits : []);
setHasMore(r.available && r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
}, [path, reread]);
const more = useCallback(() => {
if (!versions || loading) return;
const mine = ++seq.current;
setLoading(true);
void api.lanes.gitHistory
.fileHistory(target, path, { limit: PAGE, skip: versions.length })
.then((r) => {
if (seq.current !== mine || !r.available) return;
setVersions((v) => [...(v ?? []), ...r.commits]);
setHasMore(r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
}, [versions, loading, target, path]);
return (
<div data-testid="file-history" className="flex min-h-0 flex-col gap-1">
<div
data-testid="lane-changes-history-file"
className="flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
{t("lanes:changes.historyOfFile", { path })}
</span>
<button
type="button"
onClick={() => setBlame({ path, rev: "work" })}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{t("blame")}
</button>
<button
type="button"
onClick={onWholeBranch}
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("lanes:changes.historyWholeBranch")}
</button>
</div>
{error && (
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
{versions === null && !error && (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
)}
{versions && versions.length === 0 && (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.noCommits")}</p>
)}
<ul>
{versions?.map((v) => (
<li key={v.sha} className="group flex items-center gap-1 rounded px-1 hover:bg-surface-2">
<button
type="button"
onClick={() => onOpenVersion(v.path, v.sha)}
className="flex min-w-0 flex-1 flex-col py-0.5 text-left"
>
<span className="flex min-w-0 items-baseline gap-1.5">
<span
className="w-3 shrink-0 font-mono text-[10px] text-fg-muted"
title={t(`status.${v.status}`, { defaultValue: v.status })}
>
{v.status}
</span>
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{v.short}</span>
<span
className="min-w-0 flex-1 truncate text-[11px] text-fg-primary"
title={v.subject}
>
{v.subject}
</span>
</span>
<span className="flex min-w-0 items-baseline gap-1.5 pl-[1.2rem] text-[10px] text-fg-muted">
<span className="min-w-0 truncate">{v.author}</span>
<span className="shrink-0" title={new Date(v.date).toLocaleString()}>
{timeAgo(v.date)}
</span>
{v.from && (
<span className="min-w-0 [overflow-wrap:anywhere]">
{t("renamedFrom", { from: v.from })}
</span>
)}
</span>
</button>
{v.status !== "D" && (
<button
type="button"
onClick={() => setBlame({ path: v.path, rev: v.sha })}
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
{t("blame")}
</button>
)}
<button
type="button"
onClick={() => onViewCommit(v.sha)}
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
{t("commit")}
</button>
</li>
))}
</ul>
{hasMore && (
<button
type="button"
data-testid="file-history-more"
disabled={loading}
onClick={more}
className="w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
</button>
)}
{blame && (
<BlameView
target={target}
path={blame.path}
rev={blame.rev}
onClose={() => setBlame(null)}
onOpenCommit={(sha) => {
setBlame(null);
onViewCommit(sha);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,119 @@
/**
* @file ForcePushDialog.tsx
* @description "Force push (with lease)" for the CURRENT branch only: shows
* where it goes (remote and branch), the remote commit it will replace (the
* lease — the server refuses with ESTALE if the remote moved since the last
* fetch), how far ahead/behind HEAD is, and refuses a protected branch unless
* the server allows it. Push is a two-click (`useArmed`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GitTarget } from "../../../lib/api";
import { errCode, errText, gitRewrite, type PushPlan } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
export function ForcePushDialog({
target,
onClose,
onChanged,
}: {
target: GitTarget;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [plan, setPlan] = useState<PushPlan | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const key = JSON.stringify(target);
useEffect(() => {
let live = true;
gitRewrite
.planPush(target)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const run = async () => {
if (!plan) return;
if (!isArmed("push")) return arm("push");
disarm();
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "force-push", { expect: plan.remoteSha ?? "", confirm: true });
toast.success(t("forcePush.done", { remote: plan.remote, branch: plan.remoteBranch }));
onChanged();
onClose();
} catch (e) {
setError(errCode(e) === "ESTALE" ? t("forcePush.stale") : errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("forcePush.title")}
onClose={onClose}
testId="force-push-dialog"
footer={
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="force-push-run"
className={BTN_DANGER}
disabled={busy || !plan || !plan.allowed}
onClick={() => void run()}
>
{isArmed("push") ? t("forcePush.runConfirm") : t("forcePush.run")}
</button>
</>
}
>
{!plan ? (
error ? (
<ErrorLine error={error} />
) : (
<p className="text-fg-muted">{t("loading")}</p>
)
) : (
<div className="space-y-1.5">
<p data-testid="force-push-where" className="break-words">
{t("forcePush.where", {
branch: plan.branch,
remote: plan.remote,
remoteBranch: plan.remoteBranch,
})}
</p>
<p className="break-words">
{plan.remoteSha
? t("forcePush.replaces", {
sha: plan.remoteSha.slice(0, 7),
ahead: plan.ahead,
behind: plan.behind,
})
: t("forcePush.newBranch")}
</p>
{plan.behind > 0 && <Warn>{t("forcePush.dropsRemote", { count: plan.behind })}</Warn>}
{!plan.allowed && <Warn testId="force-push-protected">{t("forcePush.protected")}</Warn>}
<p className="text-[11px] text-fg-muted">{t("forcePush.lease")}</p>
<ErrorLine error={error} />
</div>
)}
</GitModal>
);
}
@@ -0,0 +1,153 @@
/**
* @file GitModal.tsx
* @description The shared modal shell of the Changes panel's git tools (merge
* editor, reflog, rebase, interactive rebase, reset, force push, remotes): a
* portal over the screen with `useDialog`'s focus contract, a title row with a
* close button, a scrolling body and an optional footer. `wide` widens it for
* the three-pane merge editor; `tall` gives a viewer (blame, browse at a
* revision, compare refs) the full height and a column body its own scrolling
* child can fill; on a phone every size is the full width. `ModalButton` is
* the History tools' footer button over the same BTN classes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useDialog } from "../../../hooks/useDialog";
export const BTN =
"rounded border border-border-light px-2 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
export const BTN_PRIMARY =
"rounded border border-accent-solid bg-accent-solid px-2 py-1 text-[11px] font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40";
export const BTN_DANGER =
"rounded border border-status-danger/60 px-2 py-1 text-[11px] font-medium text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40";
export function GitModal({
title,
onClose,
testId,
wide = false,
tall = false,
footer,
children,
}: {
title: ReactNode;
onClose: () => void;
testId?: string;
wide?: boolean;
/** A viewer: the full height, and a flex-column body. */
tall?: boolean;
footer?: ReactNode;
children: ReactNode;
}) {
const { t } = useTranslation(["gitTools"]);
const panel = useRef<HTMLDivElement>(null);
const titleId = useId();
useDialog(true, panel, onClose);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-2 sm:p-3"
onClick={onClose}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid={testId}
onClick={(e) => e.stopPropagation()}
className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${
wide || tall ? "max-w-6xl" : "max-w-lg"
} ${tall ? "h-[92vh]" : ""}`}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<h2
id={titleId}
className="min-w-0 flex-1 truncate text-sm font-semibold text-fg-primary"
>
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t("close")}
className="shrink-0 rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div
className={`min-h-0 flex-1 px-3 py-2 text-xs text-fg-secondary ${
tall ? "flex flex-col overflow-hidden" : "overflow-y-auto"
}`}
>
{children}
</div>
{footer && (
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border px-3 py-2">
{footer}
</div>
)}
</div>
</div>,
document.body
);
}
/** A red inline error line, or nothing. */
export function ErrorLine({ error }: { error: string | null }) {
if (!error) return null;
return (
<p role="alert" className="my-1 whitespace-pre-wrap break-words text-[11px] text-status-danger">
{error}
</p>
);
}
/** An amber explanation box shown before a rewrite runs. */
export function Warn({ children, testId }: { children: ReactNode; testId?: string }) {
return (
<div
data-testid={testId}
className="my-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-[11px] text-status-warning"
>
{children}
</div>
);
}
/** A footer button: plain, `primary` (filled) or `danger` (red outline). */
export function ModalButton({
onClick,
children,
primary = false,
danger = false,
disabled = false,
testId,
title,
}: {
onClick: () => void;
children: ReactNode;
primary?: boolean;
danger?: boolean;
disabled?: boolean;
testId?: string;
title?: string;
}) {
return (
<button
type="button"
data-testid={testId}
title={title}
disabled={disabled}
onClick={onClick}
className={primary ? BTN_PRIMARY : danger ? BTN_DANGER : BTN}
>
{children}
</button>
);
}
@@ -0,0 +1,210 @@
/**
* @file GitTools.tsx
* @description The Changes panel's entry points to the history-rewrite and
* remote tools: `GitToolsButton` (a toolbar menu — Rebase onto…, Interactive
* rebase…, Reflog, Force push…, Remotes…), `GitTools` (renders whichever tool
* is open, so the panel holds one `GitTool | null` state), and
* `RebaseSkipButton` (the rebase banner's Skip — Continue and Abort are the
* banner's own Complete/Abort, which the server maps onto the rebase).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { History } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { ContextMenu, MenuItem, MenuSeparator } from "../../ContextMenu";
import { ForcePushDialog } from "./ForcePushDialog";
import { InteractiveRebase } from "./InteractiveRebase";
import { MergeEditor } from "./MergeEditor";
import { RebaseDialog } from "./RebaseDialog";
import { ReflogView } from "./ReflogView";
import { RemotesDialog } from "./RemotesDialog";
import { ResetDialog } from "./ResetDialog";
export type GitTool =
| { kind: "rebase" }
| { kind: "interactive"; base?: string }
| { kind: "reset"; commit: { sha: string; short?: string; subject?: string } }
| { kind: "reflog" }
| { kind: "force-push" }
| { kind: "remotes" }
| { kind: "merge"; path: string; onResolved?: () => void };
export function GitToolsButton({
onPick,
disabled,
hasRemote,
}: {
onPick: (tool: GitTool) => void;
disabled?: boolean;
hasRemote?: boolean;
}) {
const { t } = useTranslation(["gitTools"]);
const ref = useRef<HTMLButtonElement>(null);
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
const pick = (tool: GitTool) => () => {
setAt(null);
onPick(tool);
};
return (
<>
<button
ref={ref}
type="button"
data-testid="git-tools-button"
aria-label={t("menu.label")}
title={t("menu.label")}
aria-haspopup="menu"
disabled={disabled}
onClick={() => {
const r = ref.current?.getBoundingClientRect();
setAt(r ? { x: r.left, y: r.bottom + 2 } : { x: 0, y: 0 });
}}
className="shrink-0 rounded p-0.5 text-fg-muted hover:text-fg-primary disabled:opacity-40"
>
<History className="h-3.5 w-3.5" />
</button>
{at && (
<ContextMenu x={at.x} y={at.y} onClose={() => setAt(null)} testId="git-tools-menu">
<MenuItem testId="git-tools-rebase" onSelect={pick({ kind: "rebase" })}>
{t("menu.rebase")}
</MenuItem>
<MenuItem testId="git-tools-interactive" onSelect={pick({ kind: "interactive" })}>
{t("menu.interactive")}
</MenuItem>
<MenuItem testId="git-tools-reflog" onSelect={pick({ kind: "reflog" })}>
{t("menu.reflog")}
</MenuItem>
<MenuSeparator />
<MenuItem
testId="git-tools-force-push"
danger
disabled={hasRemote === false}
onSelect={pick({ kind: "force-push" })}
>
{t("menu.forcePush")}
</MenuItem>
<MenuItem testId="git-tools-remotes" onSelect={pick({ kind: "remotes" })}>
{t("menu.remotes")}
</MenuItem>
</ContextMenu>
)}
</>
);
}
export function GitTools({
tool,
target,
branch,
onClose,
onOpen,
onChanged,
onViewCommit,
}: {
tool: GitTool | null;
target: GitTarget;
/** The current branch, for titles. */
branch: string | null;
onClose: () => void;
/** Open another tool (the reflog's "Reset here"). */
onOpen: (tool: GitTool) => void;
/** Re-read the panel's git facts after a write. */
onChanged: () => void;
onViewCommit?: (sha: string) => void;
}) {
if (!tool) return null;
switch (tool.kind) {
case "rebase":
return (
<RebaseDialog target={target} current={branch} onClose={onClose} onChanged={onChanged} />
);
case "interactive":
return (
<InteractiveRebase
target={target}
base={tool.base}
onClose={onClose}
onChanged={onChanged}
/>
);
case "reset":
return (
<ResetDialog target={target} commit={tool.commit} onClose={onClose} onChanged={onChanged} />
);
case "reflog":
return (
<ReflogView
target={target}
onClose={onClose}
onViewCommit={
onViewCommit &&
((sha) => {
onClose();
onViewCommit(sha);
})
}
onReset={(e) =>
onOpen({ kind: "reset", commit: { sha: e.sha, short: e.short, subject: e.subject } })
}
/>
);
case "force-push":
return <ForcePushDialog target={target} onClose={onClose} onChanged={onChanged} />;
case "remotes":
return <RemotesDialog target={target} onClose={onClose} onChanged={onChanged} />;
case "merge":
return (
<MergeEditor
target={target}
path={tool.path}
onClose={onClose}
onResolved={() => {
onChanged();
tool.onResolved?.();
}}
/>
);
}
}
/** The rebase banner's Skip: drops the commit the rebase stopped on. */
export function RebaseSkipButton({
target,
disabled,
onChanged,
}: {
target: GitTarget;
disabled?: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [busy, setBusy] = useState(false);
return (
<button
type="button"
data-testid="rebase-skip"
title={t("rebaseBanner.skipHint")}
disabled={disabled || busy}
onClick={async () => {
setBusy(true);
try {
const r = await gitRewrite.run(target, "rebase-skip", {});
if (r.conflicts?.length) toast.error(t("rebase.stopped", { count: r.conflicts.length }));
else if (!r.stopped) toast.success(t("rebaseBanner.finished"));
} catch (e) {
toast.error(errText(e));
} finally {
setBusy(false);
onChanged();
}
}}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{t("rebaseBanner.skip")}
</button>
);
}
@@ -0,0 +1,751 @@
/**
* @file The Changes panel's History tab, shaped like VS Code's Source Control
* Graph, GitLens and the JetBrains git log: a commit graph (lanes and merge
* edges, ref badges) read page by page and drawn windowed, so a repo with
* tens of thousands of commits stays responsive; a search box (message, a SHA
* prefix, `author:`, `path:`) and an all-branches switch; a details pane for
* the focused commit; multi-select (checkbox, Ctrl/⌘-click, Shift-click) with
* "Cherry-pick N" / "Revert N" that open a preview of exactly what will run
* (CherryPickDialog); the reference tools' commit context menu; and, after a
* pick or revert, a result banner with Undo while that is safe (the server
* refuses it once the commits are pushed or HEAD moved on).
*
* Every write still goes through the server's guards (git-pick.js); a
* disabled button here is a courtesy.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { GitCompare, Search, X } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { LaneCommit } from "../../../lib/types";
import { commitGraph } from "../../../lib/commitGraph";
import { parseSearch, inListOrder, type PickResult } from "../../../lib/gitHistory";
import { toast } from "../../../lib/toast";
import { confirmDialog } from "../../../lib/confirm";
import { copyText } from "../../../lib/clipboard";
import { Checkbox } from "../../Checkbox";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../../ContextMenu";
import { GRAPH_ROW, GraphCell, CommitText } from "./CommitGraph";
import { CommitDetails } from "./CommitDetails";
import { CherryPickDialog } from "./CherryPickDialog";
import { BrowseAtRevision } from "./BrowseAtRevision";
import { CompareRefs } from "./CompareRefs";
import { CreateBranchDialog } from "./CreateBranchDialog";
/** Commits one page reads. */
export const PAGE = 100;
/** Rows drawn beyond the visible ones, each side. */
const OVERSCAN = 20;
type Op = "cherry-pick" | "revert";
export function HistoryView({
target,
branch,
branches,
historyRef,
onHistoryRef,
reread,
disabled,
headPushed,
onViewCommit,
onOpenFileAtCommit,
onUndoCommit,
onResetHere,
onEditAfter,
onPaused,
onChanged,
}: {
target: GitTarget;
/** The branch HEAD is on (null = detached / unknown). */
branch: string | null;
/** Local branches, for "History of branch". */
branches: string[];
historyRef: string | null;
onHistoryRef: (ref: string | null) => void;
/** Bumped by the panel's Refresh and after its own writes. */
reread: number;
/** A write is running, or a merge/pick is open: nothing new starts. */
disabled: boolean;
/** The branch's upstream already holds HEAD (Undo commit refused). */
headPushed: boolean;
onViewCommit: (sha: string) => void;
onOpenFileAtCommit: (path: string, sha: string) => void;
onUndoCommit: (c: LaneCommit) => void;
/** "Reset branch to here…" — the rewrite tools' reset dialog (GitTools). */
onResetHere: (c: LaneCommit) => void;
/** "Edit commits after this one…" — the interactive rebase (GitTools). */
onEditAfter: (sha: string) => void;
/** A pick/revert stopped on a conflict: the overview resolves it. */
onPaused: () => void;
/** History changed (a pick, revert or undo): re-read everything. */
onChanged: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes", "gitTools"]);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState(() => parseSearch(""));
const [all, setAll] = useState(false);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [focused, setFocused] = useState<string | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const anchor = useRef<string | null>(null);
const [dialog, setDialog] = useState<{ op: Op; shas: string[] } | null>(null);
const [browse, setBrowse] = useState<{ sha: string; short: string } | null>(null);
const [compare, setCompare] = useState<{ a: string; b: string } | null>(null);
const [newRef, setNewRef] = useState<{
sha: string;
short: string;
kind: "branch" | "tag";
} | null>(null);
const [lastPick, setLastPick] = useState<(PickResult & { op: Op }) | null>(null);
const menu = useContextMenu<LaneCommit>();
// The search box applies after a pause in typing, not on every key.
useEffect(() => {
const id = window.setTimeout(() => setFilter(parseSearch(search)), 300);
return () => window.clearTimeout(id);
}, [search]);
const filtered = !!(filter.q || filter.author || filter.path);
const query = useMemo(
() => ({
...(all ? { all: true } : historyRef ? { ref: historyRef } : {}),
...(filter.q ? { q: filter.q } : {}),
...(filter.author ? { author: filter.author } : {}),
...(filter.path ? { path: filter.path } : {}),
}),
[all, historyRef, filter]
);
const queryKey = JSON.stringify(query);
// The first page whenever the question changes; a reply for an older
// question is dropped.
const seq = useRef(0);
useEffect(() => {
const mine = ++seq.current;
setLoading(true);
setError(null);
void api.lanes
.gitLog(target, { limit: PAGE, ...query })
.then((r) => {
if (seq.current !== mine) return;
setCommits(r.available ? r.commits : []);
setHasMore(!!r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
}, [queryKey, reread]);
const loadMore = useCallback(() => {
if (loading || !hasMore || !commits) return;
const mine = ++seq.current;
setLoading(true);
void api.lanes
.gitLog(target, { limit: PAGE, skip: commits.length, ...query })
.then((r) => {
if (seq.current !== mine) return;
setCommits((prev) => {
const seen = new Set((prev ?? []).map((c) => c.sha));
return [...(prev ?? []), ...r.commits.filter((c) => !seen.has(c.sha))];
});
setHasMore(!!r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
}, [loading, hasMore, commits, target, query]);
const list = useMemo(() => commits ?? [], [commits]);
// A search result is not a connected history: dots, no lanes.
const graph = useMemo(
() => commitGraph(filtered ? list.map((c) => ({ sha: c.sha, parents: [] })) : list),
[list, filtered]
);
const byShaIndex = useMemo(() => new Map(list.map((c, i) => [c.sha, i])), [list]);
// Which loaded commits HEAD already has: Revert applies to those,
// Cherry-pick to the rest. On HEAD's own history, all of them.
const onHead = useMemo(() => {
if (!all && !historyRef) return null;
const head = list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"));
const set = new Set<string>();
if (!head) return set;
const stack = [head.sha];
while (stack.length) {
const s = stack.pop()!;
if (set.has(s) || !byShaIndex.has(s)) continue;
set.add(s);
stack.push(...(list[byShaIndex.get(s)!]!.parents ?? []));
}
return set;
}, [all, historyRef, list, byShaIndex]);
// The commit HEAD is on: Edit commits after it has nothing to edit.
const headSha = useMemo(
() =>
list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"))?.sha ??
(!all && !historyRef ? list[0]?.sha : undefined),
[list, all, historyRef]
);
const isOnHead = useCallback((sha: string) => (onHead ? onHead.has(sha) : true), [onHead]);
// ---- selection -------------------------------------------------------
const clickRow = (e: MouseEvent, c: LaneCommit) => {
setFocused(c.sha);
if (e.shiftKey && anchor.current && byShaIndex.has(anchor.current)) {
const a = byShaIndex.get(anchor.current)!;
const b = byShaIndex.get(c.sha)!;
const [lo, hi] = a < b ? [a, b] : [b, a];
setPicked(new Set(list.slice(lo, hi + 1).map((x) => x.sha)));
return;
}
anchor.current = c.sha;
if (e.ctrlKey || e.metaKey) {
toggle(c.sha);
return;
}
setPicked(new Set());
};
const toggle = (sha: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(sha)) next.delete(sha);
else next.add(sha);
return next;
});
const selected = inListOrder(list, picked);
const mergeIn = selected.some((c) => c.parents.length > 1);
const allOnHead = selected.length > 0 && selected.every((c) => isOnHead(c.sha));
const noneOnHead = selected.length > 0 && selected.every((c) => !isOnHead(c.sha));
const target_branch = branch ?? "HEAD";
const openPick = (op: Op, shas: string[]) => setDialog({ op, shas });
const afterPick = (op: Op, r: PickResult) => {
setDialog(null);
setPicked(new Set());
if (r.paused) {
toast.error(
r.empty
? t("pausedEmpty", { op: t(`op.${op}`) })
: t("pausedConflicts", { op: t(`op.${op}`), count: r.conflicts.length })
);
onPaused();
return;
}
const n = r.commits?.length ?? 0;
toast.success(
t(op === "revert" ? "revertedN" : "pickedN", { count: n, branch: target_branch })
);
if (r.from && r.to && n) setLastPick({ ...r, op });
onChanged();
};
const undoPick = async () => {
if (!lastPick?.from || !lastPick.to) return;
const n = lastPick.commits?.length ?? 0;
const ok = await confirmDialog({
title: t("undoTitle", { op: t(`op.${lastPick.op}`) }),
message: t("undoBody", { count: n, branch: target_branch }),
confirmLabel: t("undo"),
});
if (!ok) return;
try {
await api.lanes.gitHistory.act(target, "undo-pick", {
from: lastPick.from,
to: lastPick.to,
confirm: true,
});
toast.success(t("undone", { count: n }));
setLastPick(null);
onChanged();
} catch (err) {
toast.error(err);
}
};
const copyPatch = async (c: LaneCommit) => {
try {
const r = await api.lanes.gitHistory.patch(target, c.sha);
if (!r.available) return void toast.error(t("noPatch"));
if (r.omitted.length) toast.info(t("patchOmitted", { count: r.omitted.length }));
await copyText(r.patch);
} catch (err) {
toast.error(err);
}
};
// ---- windowing -------------------------------------------------------
const scroller = useRef<HTMLDivElement>(null);
const [top, setTop] = useState(0);
const [height, setHeight] = useState(600);
useEffect(() => {
const el = scroller.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setHeight(el.clientHeight || 600));
ro.observe(el);
return () => ro.disconnect();
}, []);
const first = Math.max(0, Math.floor(top / GRAPH_ROW) - OVERSCAN);
const last = Math.min(list.length, Math.ceil((top + height) / GRAPH_ROW) + OVERSCAN);
const onScroll = () => {
const el = scroller.current;
if (!el) return;
setTop(el.scrollTop);
// Near the end: the next page, like an endless log.
if (el.scrollTop + el.clientHeight > list.length * GRAPH_ROW - GRAPH_ROW * 10) loadMore();
};
const focusedCommit = focused ? list.find((c) => c.sha === focused) : undefined;
const menuItem = menu.menu?.item;
const menuMulti = !!menuItem && picked.has(menuItem.sha) && picked.size > 1;
const pick = (fn: () => void) => () => {
menu.close();
fn();
};
return (
<div data-testid="lane-changes-history" className="flex min-h-0 flex-1 flex-col gap-1.5">
{/* Toolbar: search, all branches, branch, compare. Wraps on a narrow panel. */}
<div className="flex flex-wrap items-center gap-1.5 px-1 text-[11px]">
<label className="flex min-w-[10rem] flex-1 items-center gap-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5">
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
<input
data-testid="history-search"
value={search}
maxLength={300}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
aria-label={t("searchLabel")}
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary outline-none placeholder:text-fg-muted"
/>
{search && (
<button
type="button"
aria-label={t("clearSearch")}
onClick={() => setSearch("")}
className="shrink-0 text-fg-muted hover:text-fg-primary"
>
<X className="h-3 w-3" />
</button>
)}
</label>
<Checkbox
checked={all}
onChange={setAll}
label={t("allBranches")}
labelClassName="text-[11px] text-fg-secondary"
/>
{!all && branches.length > 1 && (
<label className="flex min-w-0 items-center gap-1 text-fg-secondary">
<span className="shrink-0">{t("lanes:changes.historyOf")}</span>
<select
data-testid="lane-changes-history-ref"
value={historyRef ?? ""}
onChange={(e) => onHistoryRef(e.target.value || null)}
className="min-w-0 max-w-[12rem] truncate rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
>
<option value="">{target_branch}</option>
{branches
.filter((b) => b !== branch)
.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</label>
)}
<button
type="button"
data-testid="history-compare"
onClick={() =>
setCompare({
a: target_branch,
b: historyRef ?? branches.find((b) => b !== branch) ?? target_branch,
})
}
className="flex shrink-0 items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
<GitCompare className="h-3 w-3" aria-hidden />
{t("compare")}
</button>
</div>
{lastPick && (
<div
data-testid="pick-result"
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-status-success/40 bg-status-success/10 px-2 py-1 text-[11px] text-status-success"
>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
{t(lastPick.op === "revert" ? "revertedN" : "pickedN", {
count: lastPick.commits?.length ?? 0,
branch: target_branch,
})}{" "}
<span className="font-mono">
{(lastPick.commits ?? []).map((c) => c.short).join(", ")}
</span>
</span>
<button
type="button"
data-testid="pick-undo"
disabled={disabled}
title={t("undoHint")}
onClick={() => void undoPick()}
className="shrink-0 rounded border border-status-success/50 px-1.5 py-0.5 hover:bg-status-success/15 disabled:opacity-40"
>
{t("undo")}
</button>
<button
type="button"
aria-label={t("dismiss")}
onClick={() => setLastPick(null)}
className="shrink-0 text-fg-muted hover:text-fg-primary"
>
<X className="h-3 w-3" />
</button>
</div>
)}
{selected.length > 0 && (
<div
data-testid="history-selection"
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-accent/40 bg-accent/10 px-2 py-1 text-[11px] text-fg-primary"
>
<span className="min-w-0 flex-1">{t("selectedN", { count: selected.length })}</span>
{noneOnHead && (
<button
type="button"
data-testid="history-pick-selected"
disabled={disabled || mergeIn}
title={mergeIn ? t("mergeCommitRefused") : undefined}
onClick={() =>
openPick(
"cherry-pick",
selected.map((c) => c.sha)
)
}
className="shrink-0 rounded bg-accent-solid px-1.5 py-0.5 text-white hover:bg-accent-solid-hover disabled:opacity-40"
>
{t("pickN", { count: selected.length, branch: target_branch })}
</button>
)}
{allOnHead && (
<button
type="button"
data-testid="history-revert-selected"
disabled={disabled || mergeIn}
title={mergeIn ? t("mergeCommitRefused") : undefined}
onClick={() =>
openPick(
"revert",
selected.map((c) => c.sha)
)
}
className="shrink-0 rounded border border-border px-1.5 py-0.5 hover:bg-surface-2 disabled:opacity-40"
>
{t("revertN", { count: selected.length })}
</button>
)}
{!noneOnHead && !allOnHead && (
<span className="text-fg-muted">{t("mixedSelection")}</span>
)}
<button
type="button"
onClick={() => setPicked(new Set())}
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("clearSelection")}
</button>
</div>
)}
{error && (
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
<div
ref={scroller}
onScroll={onScroll}
data-testid="history-list"
className="min-h-[8rem] flex-1 overflow-y-auto"
>
{commits === null ? (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
) : list.length === 0 ? (
<p className="px-1 text-[11px] text-fg-muted">
{filtered ? t("noMatches") : t("lanes:changes.noCommits")}
</p>
) : (
<div role="list" style={{ height: list.length * GRAPH_ROW, position: "relative" }}>
{list.slice(first, last).map((c, k) => {
const i = first + k;
const isPicked = picked.has(c.sha);
const head = (c.badges ?? []).some((b) => b.current || b.kind === "head");
return (
<div
key={c.sha}
role="listitem"
data-sha={c.sha}
aria-selected={isPicked || focused === c.sha}
onContextMenu={(e) => menu.open(e, c)}
style={{
position: "absolute",
top: i * GRAPH_ROW,
height: GRAPH_ROW,
left: 0,
right: 0,
}}
className={`group flex items-center gap-1 rounded px-1 ${
isPicked
? "bg-accent/15"
: focused === c.sha
? "bg-surface-3"
: "hover:bg-surface-2"
}`}
>
<Checkbox
checked={isPicked}
onChange={() => {
// A tick is an anchor too: Shift-click extends from it.
anchor.current = c.sha;
toggle(c.sha);
}}
title={t("selectCommit", { short: c.short })}
className={isPicked || picked.size ? "" : "opacity-40 group-hover:opacity-100"}
/>
<GraphCell
row={graph.rows[i]!}
width={graph.width}
merge={c.parents.length > 1}
head={head}
/>
<button
type="button"
onClick={(e) => clickRow(e, c)}
onDoubleClick={() => onViewCommit(c.sha)}
className="flex h-full min-w-0 flex-1 items-center text-left"
>
<CommitText c={c} />
</button>
</div>
);
})}
</div>
)}
{hasMore && (
<button
type="button"
data-testid="lane-changes-history-more"
disabled={loading}
onClick={loadMore}
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
>
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
</button>
)}
</div>
{focusedCommit && (
<CommitDetails
key={focusedCommit.sha}
target={target}
sha={focusedCommit.sha}
onClose={() => setFocused(null)}
onOpenCommit={(sha) => {
setFocused(sha);
const el = scroller.current?.querySelector(`[data-sha="${sha}"]`);
(el as HTMLElement | null)?.scrollIntoView?.({ block: "nearest" });
}}
onViewChanges={() => onViewCommit(focusedCommit.sha)}
onOpenFile={(path) => onOpenFileAtCommit(path, focusedCommit.sha)}
onBrowse={() => setBrowse({ sha: focusedCommit.sha, short: focusedCommit.short })}
onCompare={() => setCompare({ a: target_branch, b: focusedCommit.sha })}
onCopyPatch={() => void copyPatch(focusedCommit)}
/>
)}
{menu.menu && menuItem && (
<ContextMenu
x={menu.menu.x}
y={menu.menu.y}
onClose={menu.close}
testId="lane-changes-commit-menu"
>
<MenuItem onSelect={pick(() => onViewCommit(menuItem.sha))}>
{t("lanes:changes.viewCommit")}
</MenuItem>
<MenuItem onSelect={pick(() => setFocused(menuItem.sha))}>{t("showDetails")}</MenuItem>
<MenuItem onSelect={pick(() => setBrowse({ sha: menuItem.sha, short: menuItem.short }))}>
{t("browseFiles")}
</MenuItem>
<MenuItem onSelect={pick(() => setCompare({ a: target_branch, b: menuItem.sha }))}>
{t("compareWithCurrent")}
</MenuItem>
<MenuSeparator />
<MenuItem onSelect={pick(() => void copyText(menuItem.sha))}>
{t("lanes:changes.copySha")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(menuItem.subject))}>
{t("lanes:changes.copySubject")}
</MenuItem>
<MenuItem
disabled={menuItem.parents.length > 1}
onSelect={pick(() => void copyPatch(menuItem))}
>
{t("copyPatch")}
</MenuItem>
<MenuItem
testId="history-menu-branch"
disabled={disabled}
onSelect={pick(() =>
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "branch" })
)}
>
{t("createBranchHere")}
</MenuItem>
<MenuItem
testId="history-menu-tag"
disabled={disabled}
onSelect={pick(() =>
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "tag" })
)}
>
{t("createTagHere")}
</MenuItem>
<MenuSeparator />
{menuMulti ? (
<>
{noneOnHead && (
<MenuItem
disabled={disabled || mergeIn}
onSelect={pick(() =>
openPick(
"cherry-pick",
selected.map((c) => c.sha)
)
)}
>
{t("pickN", { count: selected.length, branch: target_branch })}
</MenuItem>
)}
{allOnHead && (
<MenuItem
disabled={disabled || mergeIn}
onSelect={pick(() =>
openPick(
"revert",
selected.map((c) => c.sha)
)
)}
>
{t("revertN", { count: selected.length })}
</MenuItem>
)}
</>
) : isOnHead(menuItem.sha) ? (
<>
<MenuItem
disabled={disabled || menuItem.parents.length !== 1}
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
testId="lane-changes-menu-revert"
onSelect={pick(() => openPick("revert", [menuItem.sha]))}
>
{t("lanes:changes.revertCommit")}
</MenuItem>
{!all && !historyRef && list[0]?.sha === menuItem.sha && (
<MenuItem
danger
disabled={disabled || headPushed || menuItem.parents.length !== 1}
title={headPushed ? t("lanes:changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit"
onSelect={pick(() => onUndoCommit(menuItem))}
>
{t("lanes:changes.undoCommit")}
</MenuItem>
)}
{/* History rewrites (GitTools): each opens a dialog that asks first. */}
<MenuItem
disabled={disabled}
testId="lane-changes-menu-reset"
onSelect={pick(() => onResetHere(menuItem))}
>
{t("gitTools:menu.resetHere")}
</MenuItem>
<MenuItem
disabled={disabled || menuItem.sha === headSha}
testId="lane-changes-menu-interactive"
onSelect={pick(() => onEditAfter(menuItem.sha))}
>
{t("gitTools:menu.interactiveHere")}
</MenuItem>
</>
) : (
<MenuItem
disabled={disabled || menuItem.parents.length !== 1}
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
testId="lane-changes-menu-cherry-pick"
onSelect={pick(() => openPick("cherry-pick", [menuItem.sha]))}
>
{t("lanes:changes.cherryPick", { branch: target_branch })}
</MenuItem>
)}
</ContextMenu>
)}
{dialog && (
<CherryPickDialog
target={target}
op={dialog.op}
shas={dialog.shas}
onClose={() => setDialog(null)}
onDone={(r) => afterPick(dialog.op, r)}
/>
)}
{browse && (
<BrowseAtRevision
target={target}
sha={browse.sha}
short={browse.short}
onClose={() => setBrowse(null)}
/>
)}
{compare && (
<CompareRefs
target={target}
initialA={compare.a}
initialB={compare.b}
refs={branches}
onClose={() => setCompare(null)}
onOpenCommit={(sha) => {
setCompare(null);
onViewCommit(sha);
}}
/>
)}
{newRef && (
<CreateBranchDialog
target={target}
sha={newRef.sha}
short={newRef.short}
kind={newRef.kind}
onClose={() => setNewRef(null)}
onDone={() => {
setNewRef(null);
onChanged();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,295 @@
/**
* @file InteractiveRebase.tsx
* @description Interactive rebase as a list (JetBrains' "Interactively Rebase
* from Here"): the branch's commits since a base — by default the ones no
* remote has yet, oldest first — each with Pick / Reword / Squash / Fixup /
* Drop, moved up and down, a new message for a reword or a squash. Before it
* runs it says what it rewrites ("This rewrites 4 commits; 2 are already
* pushed, so a force-push will be needed") and pushed commits need an explicit
* tick; Start is a two-click (`useArmed`). The server builds the todo and runs
* `git rebase -i` with no editor; a clean run stays open with **Undo**.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import {
errText,
gitRewrite,
type InteractivePlan,
type PlanCommit,
type RewriteResult,
type TodoAction,
} from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
import { blockedReason } from "./RebaseDialog";
const ACTIONS: TodoAction[] = ["pick", "reword", "squash", "fixup", "drop"];
interface Row {
commit: PlanCommit;
action: TodoAction;
message: string;
}
/** Why the list cannot run as it stands, or null. */
export function todoProblem(rows: Row[]): "first" | "allDropped" | "unchanged" | "reword" | null {
const kept = rows.filter((r) => r.action !== "drop");
if (!kept.length) return "allDropped";
if (kept[0]!.action === "squash" || kept[0]!.action === "fixup") return "first";
if (rows.some((r) => r.action === "reword" && !r.message.trim())) return "reword";
return null;
}
export function InteractiveRebase({
target,
base,
onClose,
onChanged,
}: {
target: GitTarget;
/** Rewrite the commits after this one; absent = the unpushed ones. */
base?: string;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [plan, setPlan] = useState<InteractivePlan | null>(null);
const [rows, setRows] = useState<Row[]>([]);
const [force, setForce] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RewriteResult | null>(null);
const { isArmed, arm, disarm } = useArmed();
const key = JSON.stringify(target);
useEffect(() => {
let live = true;
gitRewrite
.planInteractive(target, base)
.then((p) => {
if (!live) return;
setPlan(p);
setRows(p.commits.map((c) => ({ commit: c, action: "pick", message: c.message })));
})
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, base]);
const set = (i: number, patch: Partial<Row>) =>
setRows((prev) => prev.map((r, n) => (n === i ? { ...r, ...patch } : r)));
const move = (i: number, step: number) =>
setRows((prev) => {
const j = i + step;
if (j < 0 || j >= prev.length) return prev;
const next = [...prev];
[next[i], next[j]] = [next[j]!, next[i]!];
return next;
});
const blocked = plan ? blockedReason(t, plan) : null;
const problem = todoProblem(rows);
const unchanged =
!!plan && rows.every((r, i) => r.action === "pick" && r.commit.sha === plan.commits[i]?.sha);
const pushed = plan?.pushed ?? 0;
const run = async () => {
if (!plan) return;
if (!isArmed("run")) return arm("run");
disarm();
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "rebase-interactive", {
head: plan.head,
...(base ? { base: plan.base } : {}),
confirm: true,
...(pushed ? { force: true } : {}),
todo: rows.map((row) => ({
sha: row.commit.sha,
action: row.action,
...((row.action === "reword" || row.action === "squash") &&
row.message.trim() &&
row.message.trim() !== row.commit.message
? { message: row.message }
: row.action === "reword"
? { message: row.message }
: {}),
})),
});
onChanged();
if (r.conflicts?.length || r.stopped) {
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
onClose();
return;
}
toast.success(t("interactive.done"));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("interactive.title")}
onClose={onClose}
testId="interactive-rebase"
footer={
result ? (
<button type="button" className={BTN} onClick={onClose}>
{t("close")}
</button>
) : (
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="interactive-run"
className={BTN_DANGER}
disabled={
busy || !plan || !!blocked || !!problem || unchanged || (pushed > 0 && !force)
}
onClick={() => void run()}
>
{isArmed("run") ? t("interactive.runConfirm") : t("interactive.run")}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : !plan ? (
error ? (
<ErrorLine error={error} />
) : (
<p className="text-fg-muted">{t("loading")}</p>
)
) : (
<>
<p className="mb-1.5 text-[11px] text-fg-muted">{t("interactive.explain")}</p>
{blocked && <Warn>{blocked}</Warn>}
{plan.merges && <Warn>{t("interactive.merges")}</Warn>}
{!rows.length ? (
<p data-testid="interactive-empty">{t("interactive.nothing")}</p>
) : (
<>
<Warn testId="interactive-summary">
{pushed
? t("rewritesPushed", { count: rows.length, pushed })
: t("rewrites", { count: rows.length })}
{pushed > 0 && (
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="interactive-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
)}
</Warn>
<ol data-testid="interactive-list" className="space-y-1">
{rows.map((r, i) => (
<li
key={r.commit.sha}
data-testid="interactive-row"
className={`rounded border border-border-light p-1.5 ${
r.action === "drop" ? "opacity-50" : ""
}`}
>
<div className="flex flex-wrap items-center gap-1.5">
<select
aria-label={t("interactive.action", { short: r.commit.short })}
data-testid="interactive-action"
value={r.action}
onChange={(e) => set(i, { action: e.target.value as TodoAction })}
className="input py-0.5 text-[11px]"
>
{ACTIONS.map((a) => (
<option key={a} value={a}>
{t(`interactive.actions.${a}`)}
</option>
))}
</select>
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
{r.commit.short}
</span>
<span
className={`min-w-0 flex-1 truncate text-[11px] text-fg-primary ${
r.action === "drop" ? "line-through" : ""
}`}
title={r.commit.subject}
>
{r.commit.subject}
</span>
{r.commit.pushed && (
<span className="shrink-0 text-[10px] text-status-warning">
{t("pushed")}
</span>
)}
<button
type="button"
className={BTN}
aria-label={t("interactive.up")}
disabled={i === 0}
onClick={() => move(i, -1)}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
className={BTN}
aria-label={t("interactive.down")}
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
{(r.action === "reword" || r.action === "squash") && (
<textarea
data-testid="interactive-message"
aria-label={t("interactive.message")}
value={r.message}
rows={3}
onChange={(e) => set(i, { message: e.target.value })}
placeholder={
r.action === "squash" ? t("interactive.squashHint") : undefined
}
className="input mt-1 w-full font-mono text-[11px]"
/>
)}
</li>
))}
</ol>
{problem && (
<p
data-testid="interactive-problem"
className="mt-1 text-[11px] text-status-danger"
>
{t(`interactive.problem.${problem}`)}
</p>
)}
</>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}
@@ -0,0 +1,444 @@
/**
* @file MergeEditor.tsx
* @description The 3-way merge editor for ONE conflicted file — a merge,
* cherry-pick, revert or rebase (VS Code's / JetBrains' layout): Incoming
* (theirs, git stage :3) | Result | Current (ours, stage :2), with the common
* base (stage :1) one click away. Each conflict block of the result takes
* Accept current / Accept incoming / Accept both (either order) / a hand edit,
* undoable; next/previous conflict and "N conflicts left"; or the whole result
* edited as text. Save writes the result through the `conflict-save` action —
* the whole-file editor's own guards (baseSha → 409 ESTALE, secret refusal)
* then `git add` — which the server refuses while a marker is left.
* In a rebase git's "ours" is the branch being rebased ONTO and "theirs" the
* commit being replayed, so the labels say that rather than "mine".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { parseConflicts, type ConflictPart } from "../../../lib/conflicts";
import { errCode, errText, gitRewrite, type ConflictSides } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
export type BlockChoice =
{ kind: "ours" | "theirs" | "ours-theirs" | "theirs-ours" } | { kind: "custom"; text: string };
type Block = Extract<ConflictPart, { kind: "conflict" }>;
/** The lines a block resolves to, or null while it is unresolved. */
export function blockLines(p: Block, c: BlockChoice | undefined): string[] | null {
if (!c) return null;
if (c.kind === "custom") return c.text === "" ? [] : c.text.split("\n");
if (c.kind === "ours") return p.ours;
if (c.kind === "theirs") return p.theirs;
if (c.kind === "ours-theirs") return [...p.ours, ...p.theirs];
return [...p.theirs, ...p.ours];
}
/** The result file: resolved blocks as chosen, unresolved ones with their markers. */
export function composeResult(parts: ConflictPart[], choices: (BlockChoice | undefined)[]): string {
const out: string[] = [];
let i = 0;
for (const p of parts) {
if (p.kind === "text") {
out.push(...p.lines);
continue;
}
const lines = blockLines(p, choices[i++]);
if (lines) out.push(...lines);
else
out.push(
`<<<<<<< ${p.oursLabel}`.trimEnd(),
...p.ours,
"=======",
...p.theirs,
`>>>>>>> ${p.theirsLabel}`.trimEnd()
);
}
return out.join("\n");
}
function Pane({
title,
text,
testId,
tone,
}: {
title: string;
text: string | null;
testId: string;
tone: "incoming" | "current" | "base";
}) {
const { t } = useTranslation(["gitTools"]);
const head =
tone === "incoming"
? "bg-accent/20 text-accent"
: tone === "current"
? "bg-status-success/20 text-status-success"
: "bg-surface-3 text-fg-secondary";
return (
<section
data-testid={testId}
className="flex min-h-0 min-w-0 flex-col rounded border border-border-light"
>
<h3 className={`truncate px-2 py-1 text-[11px] font-semibold ${head}`} title={title}>
{title}
</h3>
<pre className="max-h-60 min-h-0 flex-1 overflow-auto whitespace-pre bg-surface-0 px-2 py-1 font-mono text-[11px] leading-5 text-fg-primary md:max-h-none">
{text == null ? <span className="italic text-fg-muted">{t("merge.sideGone")}</span> : text}
</pre>
</section>
);
}
export function MergeEditor({
target,
path,
onClose,
onResolved,
}: {
target: GitTarget;
path: string;
onClose: () => void;
/** The file was saved and marked resolved. */
onResolved: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [sides, setSides] = useState<ConflictSides | null>(null);
const [choices, setChoices] = useState<(BlockChoice | undefined)[]>([]);
const [editing, setEditing] = useState<{ i: number; text: string } | null>(null);
const [whole, setWhole] = useState<string | null>(null);
const [showBase, setShowBase] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [at, setAt] = useState(0);
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
const load = useCallback(() => {
let live = true;
setSides(null);
setChoices([]);
setEditing(null);
setWhole(null);
setError(null);
gitRewrite
.conflict(target, path)
.then((s) => live && setSides(s))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// `target` is rebuilt by the caller on every render; its parts are stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(target), path]);
useEffect(load, [load]);
const parts =
sides?.usable && sides.working != null ? parseConflicts(sides.working.split("\n")) : null;
const blocks = (parts ?? []).filter((p): p is Block => p.kind === "conflict");
const total = blocks.length;
const left = blocks.filter((_, i) => !choices[i]).length;
const rebase = sides?.operation === "rebase";
const incomingLabel = sides?.incoming
? t(rebase ? "merge.incomingRebase" : "merge.incoming", {
ref: `${sides.incoming.short} ${sides.incoming.subject}`,
})
: t("merge.incomingPlain");
const currentLabel = t(rebase ? "merge.currentRebase" : "merge.current", {
branch: sides?.branch ?? "HEAD",
});
const choose = (i: number, c: BlockChoice | undefined) =>
setChoices((prev) => {
const next = [...prev];
next[i] = c;
return next;
});
const jump = (step: number) => {
if (!total) return;
const open = blocks.map((_, i) => i).filter((i) => !choices[i]);
const pool = open.length ? open : blocks.map((_, i) => i);
const from = pool.findIndex((i) => i > at);
let i: number;
if (step > 0) i = from === -1 ? pool[0]! : pool[from]!;
else {
const before = pool.filter((x) => x < at);
i = before.length ? before[before.length - 1]! : pool[pool.length - 1]!;
}
setAt(i);
blockRefs.current[i]?.scrollIntoView?.({ block: "center" });
};
const save = async () => {
if (!sides?.sha || !parts) return;
const content = whole ?? composeResult(parts, choices);
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "conflict-save", { path, content, baseSha: sides.sha });
toast.success(t("merge.saved", { path }));
onResolved();
onClose();
} catch (e) {
const code = errCode(e);
setError(
code === "EMARKERS"
? t("merge.markersLeft")
: code === "ESTALE"
? t("merge.stale")
: errText(e)
);
} finally {
setBusy(false);
}
};
const choiceBtn = (i: number, c: BlockChoice, label: string, testId: string) => (
<button type="button" data-testid={testId} className={BTN} onClick={() => choose(i, c)}>
{label}
</button>
);
let blockIndex = -1;
const result = parts && (
<section
data-testid="merge-result"
className="flex min-h-0 min-w-0 flex-col rounded border border-accent/50"
>
<h3 className="truncate bg-surface-3 px-2 py-1 text-[11px] font-semibold text-fg-primary">
{t("merge.result")}
</h3>
{whole != null ? (
<textarea
data-testid="merge-whole"
aria-label={t("merge.result")}
value={whole}
onChange={(e) => setWhole(e.target.value)}
spellCheck={false}
className="input min-h-60 flex-1 resize-none whitespace-pre rounded-none border-0 font-mono text-[11px] leading-5"
/>
) : (
<div className="max-h-[50vh] min-h-0 flex-1 overflow-auto bg-surface-0 md:max-h-none">
{parts.map((p, n) => {
if (p.kind === "text")
return (
<pre
key={n}
className="whitespace-pre px-2 font-mono text-[11px] leading-5 text-fg-primary"
>
{p.lines.length ? p.lines.join("\n") : " "}
</pre>
);
const i = ++blockIndex;
const c = choices[i];
const lines = blockLines(p, c);
return (
<div
key={n}
ref={(el) => {
blockRefs.current[i] = el;
}}
data-testid="merge-block"
className={`my-1 border-y-2 ${
c ? "border-status-success/60" : "border-status-warning/70"
} ${at === i ? "ring-1 ring-accent" : ""}`}
>
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-1.5 py-1 text-[11px]">
<span className="mr-auto text-fg-muted">
{t("merge.blockOf", { n: i + 1, total })}
</span>
{editing?.i === i ? (
<button
type="button"
className={BTN}
onClick={() => {
choose(i, { kind: "custom", text: editing.text });
setEditing(null);
}}
>
{t("merge.editDone")}
</button>
) : c ? (
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
{t("merge.undoChoice")}
</button>
) : (
<>
{choiceBtn(
i,
{ kind: "ours" },
t("merge.acceptCurrent"),
"merge-accept-current"
)}
{choiceBtn(
i,
{ kind: "theirs" },
t("merge.acceptIncoming"),
"merge-accept-incoming"
)}
{choiceBtn(
i,
{ kind: "ours-theirs" },
t("merge.bothCurrentFirst"),
"merge-both-ct"
)}
{choiceBtn(
i,
{ kind: "theirs-ours" },
t("merge.bothIncomingFirst"),
"merge-both-ic"
)}
</>
)}
{editing?.i !== i && (
<button
type="button"
data-testid="merge-edit-block"
className={BTN}
onClick={() =>
setEditing({ i, text: (lines ?? [...p.ours, ...p.theirs]).join("\n") })
}
>
{t("merge.editBlock")}
</button>
)}
</div>
{editing?.i === i ? (
<textarea
data-testid="merge-block-text"
aria-label={t("merge.editBlock")}
value={editing.text}
onChange={(e) => setEditing({ i, text: e.target.value })}
spellCheck={false}
rows={Math.min(14, Math.max(3, editing.text.split("\n").length + 1))}
className="input w-full whitespace-pre rounded-none font-mono text-[11px] leading-5"
/>
) : lines ? (
<pre className="whitespace-pre bg-status-success/10 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{lines.length ? lines.join("\n") : " "}
</pre>
) : (
<>
<pre className="whitespace-pre border-l-4 border-status-success bg-status-success/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{p.ours.length ? p.ours.join("\n") : " "}
</pre>
<pre className="whitespace-pre border-l-4 border-accent bg-accent/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{p.theirs.length ? p.theirs.join("\n") : " "}
</pre>
</>
)}
</div>
);
})}
</div>
)}
</section>
);
return (
<GitModal
wide
testId="merge-editor"
title={t("merge.title", { path })}
onClose={onClose}
footer={
<>
<ErrorLine error={error} />
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="merge-save"
className={BTN_PRIMARY}
disabled={busy || !parts || !sides?.sha || (whole == null && left > 0)}
onClick={() => void save()}
>
{t("merge.save")}
</button>
</>
}
>
{!sides ? (
!error && <p className="text-fg-muted">{t("loading")}</p>
) : !sides.usable || !parts ? (
<p data-testid="merge-unusable" className="text-status-warning">
{sides.binary
? t("merge.binary")
: sides.tooBig
? t("merge.tooBig")
: sides.working == null
? t("merge.noWorking")
: t("merge.unbalanced")}
</p>
) : (
<div className="flex min-h-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<span
data-testid="merge-left"
className={`text-[11px] ${left ? "text-status-warning" : "text-status-success"}`}
>
{whole != null
? t("merge.wholeMode")
: left
? t("merge.left", { count: left, total })
: t("merge.allChosen")}
</span>
<button
type="button"
className={BTN}
aria-label={t("merge.prev")}
disabled={!total || whole != null}
onClick={() => jump(-1)}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className={BTN}
aria-label={t("merge.next")}
disabled={!total || whole != null}
onClick={() => jump(1)}
>
<ChevronDown className="h-3 w-3" />
</button>
<label className="ml-auto flex items-center gap-1 text-[11px]">
<input
type="checkbox"
checked={showBase}
onChange={(e) => setShowBase(e.target.checked)}
/>
{t("merge.showBase")}
</label>
<button
type="button"
data-testid="merge-whole-toggle"
className={BTN}
onClick={() => setWhole(whole == null ? composeResult(parts, choices) : null)}
>
{whole == null ? t("merge.editWhole") : t("merge.backToBlocks")}
</button>
</div>
{showBase && (
<Pane title={t("merge.base")} text={sides.base} testId="merge-base" tone="base" />
)}
<div className="grid min-h-0 grid-cols-1 gap-2 md:h-[60vh] md:grid-cols-3">
<Pane
title={incomingLabel}
text={sides.theirs}
testId="merge-incoming"
tone="incoming"
/>
{result}
<Pane title={currentLabel} text={sides.ours} testId="merge-current" tone="current" />
</div>
</div>
)}
</GitModal>
);
}
@@ -0,0 +1,154 @@
/**
* @file The banner a paused cherry-pick or revert shows above the Changes
* panel (the reference tools' "sequencer" bar): "Cherry-pick paused at commit
* X (2 of 5): resolve the conflicted files, then Continue — or Skip this
* commit, or Abort to go back to where you were", with those three buttons.
* Continue is disabled while a file is still conflicted; Skip and Abort throw
* work away, so each takes a second click (`useArmed`) and sends
* `confirm: true`. Reads GET /git/sequence; acts through git-pick.js.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import type { PickAction, PickSequence } from "../../../lib/gitHistory";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
type Active = Extract<PickSequence, { active: true }>;
export function PickBanner({
target,
conflicts,
reread,
busy,
onBusy,
onDone,
}: {
target: GitTarget;
/** The git facts' conflict count: a change re-reads the sequence. */
conflicts: number;
reread: number;
busy: boolean;
onBusy: (busy: boolean) => void;
/** A button ran: re-read the facts and the changes. */
onDone: () => void;
}) {
const { t } = useTranslation(["gitHistory"]);
const [seq, setSeq] = useState<Active | null>(null);
const [error, setError] = useState<string | null>(null);
const { arm, disarm, isArmed } = useArmed();
useEffect(() => {
let live = true;
void api.lanes.gitHistory
.sequence(target)
.then((r) => live && setSeq(r.available && r.active ? r : null))
.catch(() => live && setSeq(null));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
}, [conflicts, reread]);
if (!seq) return null;
const op = t(`op.${seq.operation}`);
const step = seq.done + 1;
const total = seq.done + 1 + seq.remaining.length;
const cur = seq.current;
const left = seq.conflicts.length;
const act = async (action: PickAction) => {
disarm();
onBusy(true);
setError(null);
try {
const r = await api.lanes.gitHistory.act(
target,
action,
action === "pick-continue" ? {} : { confirm: true }
);
if (action === "pick-abort") toast.success(t("aborted", { op }));
else if (r.paused) toast.error(t("pausedConflicts", { op, count: r.conflicts.length }));
else toast.success(t("sequenceDone", { op, count: r.commits?.length ?? 0 }));
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
onBusy(false);
}
};
const btn =
"shrink-0 rounded border px-1.5 py-0.5 disabled:cursor-not-allowed disabled:opacity-40";
return (
<div
data-testid="pick-banner"
className="mb-2 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
>
<p className="[overflow-wrap:anywhere]">
<strong>
{cur
? t("pausedAt", { op, short: cur.short, subject: cur.subject, step, total })
: t("pausedBetween", { op, step, total })}
</strong>{" "}
{left
? t("pausedHelpConflicts", { count: left })
: cur
? t("pausedHelpReady")
: t("pausedHelpResume")}
</p>
{seq.remaining.length > 0 && (
<p className="mt-0.5 text-fg-muted [overflow-wrap:anywhere]">
{t("stillToApply", {
list: seq.remaining.map((c) => c.short).join(", "),
count: seq.remaining.length,
})}
</p>
)}
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<button
type="button"
data-testid="pick-continue"
disabled={busy || left > 0}
title={left ? t("continueBlocked") : undefined}
onClick={() => void act("pick-continue")}
className={`${btn} border-accent/40 bg-accent/15 text-accent hover:bg-accent/25`}
>
{t("continue")}
</button>
{cur && (
<button
type="button"
data-testid="pick-skip"
disabled={busy}
title={t("skipHint")}
onClick={() => (isArmed("skip") ? void act("pick-skip") : arm("skip"))}
className={`${btn} border-border text-fg-secondary hover:bg-surface-2`}
>
{isArmed("skip") ? t("skipConfirm") : t("skip")}
</button>
)}
<button
type="button"
data-testid="pick-abort"
disabled={busy}
title={t("abortHint")}
onClick={() => (isArmed("abort") ? void act("pick-abort") : arm("abort"))}
className={`${btn} border-status-danger/40 text-status-danger hover:bg-status-danger/10`}
>
{isArmed("abort") ? t("abortConfirm", { op }) : t("abort")}
</button>
</div>
{error && (
<p
role="alert"
className="mt-1 whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
>
{error}
</p>
)}
</div>
);
}
@@ -0,0 +1,207 @@
/**
* @file RebaseDialog.tsx
* @description "Rebase current branch onto…": pick a local or fetched remote
* branch, see the plan before anything runs (GET /git/rewrite-plan
* kind=rebase — how many commits are replayed, how many are already pushed and
* so will need a force-push, how far behind the branch is), then `rebase`.
* A conflict closes the dialog: the Changes panel's banner then offers
* Continue / Skip / Abort and the merge editor resolves the files. A clean
* rebase stays open on its result with **Undo**.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type RebasePlan, type RewriteResult } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_PRIMARY, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
/** Why a rewrite cannot start, from the plan's common facts — or null. */
export function blockedReason(
t: (k: string, o?: Record<string, unknown>) => string,
p: { subdir: boolean; operation: string | null; dirty: boolean },
needsClean = true
): string | null {
if (p.subdir) return t("blocked.subdir");
if (p.operation) return t("blocked.operation", { op: p.operation });
if (needsClean && p.dirty) return t("blocked.dirty");
return null;
}
export function RebaseDialog({
target,
current,
onClose,
onChanged,
}: {
target: GitTarget;
current: string | null;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [refs, setRefs] = useState<string[]>([]);
const [onto, setOnto] = useState("");
const [plan, setPlan] = useState<RebasePlan | null>(null);
const [force, setForce] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RewriteResult | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
api.lanes
.gitBranches(target)
.then((b) => {
const list = [...(b.branches ?? []), ...(b.remotes ?? [])].filter((x) => x !== current);
setRefs(list);
})
.catch((e) => setError(errText(e)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(() => {
setPlan(null);
setForce(false);
if (!onto) return;
let live = true;
gitRewrite
.planRebase(target, onto)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, onto]);
const blocked = plan ? blockedReason(t, plan) : null;
const run = async () => {
if (!plan) return;
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "rebase", {
onto,
head: plan.head,
...(plan.pushed ? { force: true } : {}),
});
onChanged();
if (r.conflicts?.length || r.stopped) {
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
onClose();
return;
}
toast.success(t("rebase.done", { onto }));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("rebase.title", { branch: current ?? "HEAD" })}
onClose={onClose}
testId="rebase-dialog"
footer={
result ? (
<button type="button" className={BTN} onClick={onClose}>
{t("close")}
</button>
) : (
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="rebase-run"
className={BTN_PRIMARY}
disabled={
busy || !plan || !!blocked || !plan.commits.length || (plan.pushed > 0 && !force)
}
onClick={() => void run()}
>
{t("rebase.run")}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : (
<>
<label className="mb-1 block text-[11px]" htmlFor="rebase-onto">
{t("rebase.onto")}
</label>
<select
id="rebase-onto"
data-testid="rebase-onto"
value={onto}
onChange={(e) => setOnto(e.target.value)}
className="input mb-2 w-full py-1 text-[11px]"
>
<option value="">{t("rebase.pick")}</option>
{refs.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<p className="mb-1 text-[11px] text-fg-muted">{t("rebase.explain")}</p>
{plan && (
<div data-testid="rebase-plan" className="space-y-1">
{blocked ? (
<Warn>{blocked}</Warn>
) : !plan.commits.length ? (
<p>{t("rebase.nothing", { onto })}</p>
) : (
<>
<p>
{t("rebase.summary", {
count: plan.commits.length,
onto,
behind: plan.behind,
})}
</p>
<ul className="max-h-40 overflow-y-auto rounded border border-border-light">
{plan.commits.map((c) => (
<li key={c.sha} className="flex gap-1.5 px-1.5 py-0.5 text-[11px]">
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-fg-primary">{c.subject}</span>
{c.pushed && (
<span className="shrink-0 text-status-warning">{t("pushed")}</span>
)}
</li>
))}
</ul>
{plan.pushed > 0 && (
<Warn testId="rebase-pushed">
{t("rewritesPushed", { count: plan.commits.length, pushed: plan.pushed })}
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="rebase-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
</Warn>
)}
</>
)}
</div>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}
@@ -0,0 +1,157 @@
/**
* @file ReflogView.tsx
* @description The read-only reflog of HEAD or of one local branch, paged
* (GET /git/reflog): each entry's selector, what moved it (commit, rebase,
* reset, checkout…), git's message, when, and the commit it points at — a
* click opens that commit in the Changes panel. "Reset here" hands the entry
* to the reset dialog, which is how a lost commit is brought back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type ReflogEntry } from "../../../lib/gitRewrite";
import { BTN, ErrorLine, GitModal } from "./GitModal";
const PAGE = 50;
function when(time: number | null, lang: string): string {
if (!time) return "";
try {
return new Date(time * 1000).toLocaleString(lang);
} catch {
return new Date(time * 1000).toISOString();
}
}
export function ReflogView({
target,
onClose,
onViewCommit,
onReset,
}: {
target: GitTarget;
onClose: () => void;
/** Open one commit (the panel's commit view). */
onViewCommit?: (sha: string) => void;
/** Open the reset dialog at this commit. */
onReset?: (entry: ReflogEntry) => void;
}) {
const { t, i18n } = useTranslation(["gitTools"]);
const [ref, setRef] = useState("HEAD");
const [branches, setBranches] = useState<string[]>([]);
const [entries, setEntries] = useState<ReflogEntry[] | null>(null);
const [more, setMore] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
api.lanes
.gitBranches(target)
.then((b) => setBranches(b.branches ?? []))
.catch(() => setBranches([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const page = async (skip: number) => {
setBusy(true);
setError(null);
try {
const r = await gitRewrite.reflog(target, { ref, skip, limit: PAGE });
setEntries((prev) => (skip && prev ? [...prev, ...r.entries] : r.entries));
setMore(r.more);
} catch (e) {
setError(errText(e));
if (!skip) setEntries([]);
} finally {
setBusy(false);
}
};
useEffect(() => {
setEntries(null);
void page(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, ref]);
return (
<GitModal title={t("reflog.title")} onClose={onClose} testId="reflog-view">
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<label className="text-[11px]" htmlFor="reflog-ref">
{t("reflog.of")}
</label>
<select
id="reflog-ref"
data-testid="reflog-ref"
value={ref}
onChange={(e) => setRef(e.target.value)}
className="input min-w-0 max-w-full flex-1 py-0.5 text-[11px]"
>
<option value="HEAD">HEAD</option>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
<p className="mb-1.5 text-[11px] text-fg-muted">{t("reflog.hint")}</p>
<ErrorLine error={error} />
{entries == null ? (
<p className="text-fg-muted">{t("loading")}</p>
) : !entries.length ? (
<p className="text-fg-muted">{t("reflog.empty")}</p>
) : (
<ul data-testid="reflog-list" className="divide-y divide-border">
{entries.map((e) => (
<li key={`${e.index}-${e.sha}`} className="flex flex-wrap items-start gap-1.5 py-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{e.selector}</span>
<span className="shrink-0 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
{e.action}
</span>
<span className="min-w-0 flex-1 break-words text-[11px] text-fg-primary">
{e.message || e.subject}
<span className="block text-[10px] text-fg-muted">
{when(e.time, i18n.language)}
</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{onViewCommit ? (
<button
type="button"
className="font-mono text-[11px] text-accent hover:underline"
title={e.subject}
onClick={() => onViewCommit(e.sha)}
>
{e.short}
</button>
) : (
<span className="font-mono text-[11px]" title={e.subject}>
{e.short}
</span>
)}
{onReset && (
<button type="button" className={BTN} onClick={() => onReset(e)}>
{t("reflog.resetHere")}
</button>
)}
</span>
</li>
))}
</ul>
)}
{more && (
<button
type="button"
data-testid="reflog-more"
disabled={busy}
className={`${BTN} mt-2 w-full`}
onClick={() => void page(entries?.length ?? 0)}
>
{t("reflog.more")}
</button>
)}
</GitModal>
);
}
@@ -0,0 +1,247 @@
/**
* @file RemotesDialog.tsx
* @description The repo's remotes: each one's URL (no password is ever
* shown — the server strips it) and default branch; add one (name + an
* https:// with no login, ssh:// or user@host:path URL — the server's
* allowlist), rename one, remove one (two clicks), and delete a branch ON a
* remote (two clicks; never its default branch nor main/master/develop —
* the server refuses those whatever this shows).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type GitRemote } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, BTN_DANGER, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
const PROTECTED = new Set(["main", "master", "development", "develop"]);
export function RemotesDialog({
target,
onClose,
onChanged,
}: {
target: GitTarget;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [remotes, setRemotes] = useState<GitRemote[] | null>(null);
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [renaming, setRenaming] = useState<{ from: string; to: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const key = JSON.stringify(target);
const load = useCallback(() => {
gitRewrite
.remotes(target)
.then((r) => setRemotes(r.remotes))
.catch((e) => setError(errText(e)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(load, [load]);
const act = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
setError(null);
try {
await fn();
toast.success(ok);
onChanged();
load();
return true;
} catch (e) {
setError(errText(e));
return false;
} finally {
setBusy(false);
}
};
const add = async () => {
const n = name.trim();
const u = url.trim();
if (
await act(
() => gitRewrite.run(target, "remote-add", { name: n, url: u }),
t("remotes.added", { name: n })
)
) {
setName("");
setUrl("");
}
};
const rename = async () => {
if (!renaming) return;
const { from, to } = renaming;
if (
await act(
() => gitRewrite.run(target, "remote-rename", { name: from, to: to.trim() }),
t("remotes.renamed", { from, to: to.trim() })
)
)
setRenaming(null);
};
const remove = (n: string) => {
if (!isArmed(`rm:${n}`)) return arm(`rm:${n}`);
disarm();
void act(
() => gitRewrite.run(target, "remote-remove", { name: n, confirm: true }),
t("remotes.removed", { name: n })
);
};
const deleteBranch = (ref: string) => {
if (!isArmed(`del:${ref}`)) return arm(`del:${ref}`);
disarm();
void act(
() => gitRewrite.run(target, "remote-branch-delete", { branch: ref, confirm: true }),
t("remotes.branchDeleted", { ref })
);
};
return (
<GitModal title={t("remotes.title")} onClose={onClose} testId="remotes-dialog">
{remotes == null ? (
!error && <p className="text-fg-muted">{t("loading")}</p>
) : !remotes.length ? (
<p className="mb-2">{t("remotes.none")}</p>
) : (
<ul data-testid="remotes-list" className="mb-3 space-y-2">
{remotes.map((r) => (
<li key={r.name} className="rounded border border-border-light p-1.5">
<div className="flex flex-wrap items-center gap-1.5">
{renaming?.from === r.name ? (
<input
aria-label={t("remotes.newName")}
data-testid="remote-rename-input"
value={renaming.to}
onChange={(e) => setRenaming({ from: r.name, to: e.target.value })}
className="input min-w-0 flex-1 py-0.5 font-mono text-[11px]"
/>
) : (
<span className="min-w-0 flex-1 truncate font-mono text-[11px] font-semibold text-fg-primary">
{r.name}
</span>
)}
{renaming?.from === r.name ? (
<>
<button
type="button"
className={BTN_PRIMARY}
disabled={busy || !renaming.to.trim()}
onClick={() => void rename()}
>
{t("remotes.rename")}
</button>
<button type="button" className={BTN} onClick={() => setRenaming(null)}>
{t("cancel")}
</button>
</>
) : (
<>
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setRenaming({ from: r.name, to: r.name })}
>
{t("remotes.rename")}
</button>
<button
type="button"
data-testid={`remote-remove-${r.name}`}
className={BTN_DANGER}
disabled={busy}
onClick={() => remove(r.name)}
>
{isArmed(`rm:${r.name}`) ? t("remotes.removeConfirm") : t("remotes.remove")}
</button>
</>
)}
</div>
<p className="mt-0.5 break-all font-mono text-[10px] text-fg-muted">{r.url}</p>
{r.defaultBranch && (
<p className="text-[10px] text-fg-muted">
{t("remotes.default", { branch: r.defaultBranch })}
</p>
)}
{!!r.branches?.length && (
<details className="mt-1">
<summary className="cursor-pointer text-[11px]">
{t("remotes.branches", { count: r.branches.length })}
</summary>
<ul className="mt-1 max-h-40 space-y-0.5 overflow-y-auto">
{r.branches.map((b) => {
const ref = `${r.name}/${b}`;
const locked = PROTECTED.has(b) || b === r.defaultBranch;
return (
<li key={b} className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{b}</span>
<button
type="button"
data-testid={`remote-branch-delete-${ref}`}
className={BTN_DANGER}
disabled={busy || locked}
title={locked ? t("remotes.branchLocked") : undefined}
onClick={() => deleteBranch(ref)}
>
{isArmed(`del:${ref}`)
? t("remotes.deleteBranchConfirm")
: t("remotes.deleteBranch")}
</button>
</li>
);
})}
</ul>
</details>
)}
</li>
))}
</ul>
)}
<form
onSubmit={(e) => {
e.preventDefault();
void add();
}}
className="space-y-1.5 rounded border border-border p-1.5"
>
<p className="text-[11px] font-medium text-fg-primary">{t("remotes.add")}</p>
<input
aria-label={t("remotes.name")}
placeholder={t("remotes.name")}
data-testid="remote-add-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input w-full font-mono text-[11px]"
/>
<input
aria-label={t("remotes.url")}
placeholder="https://host/owner/repo.git"
data-testid="remote-add-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input w-full font-mono text-[11px]"
/>
<p className="text-[10px] text-fg-muted">{t("remotes.urlHint")}</p>
<div className="flex justify-end">
<button
type="submit"
data-testid="remote-add-submit"
className={BTN_PRIMARY}
disabled={busy || !name.trim() || !url.trim()}
>
{t("remotes.addButton")}
</button>
</div>
</form>
<ErrorLine error={error} />
</GitModal>
);
}
@@ -0,0 +1,211 @@
/**
* @file ResetDialog.tsx
* @description "Reset current branch to here": the plan first (GET
* /git/rewrite-plan kind=reset — how many commits leave or join the branch and
* how many of those leaving are already pushed), then one of four modes, each
* explained: soft and mixed never touch the working tree, keep refuses to
* overwrite a local change, and hard runs only on a clean tree with the branch
* name typed back. Pushed commits need an explicit tick (a force-push will be
* needed). A done reset stays open on its result with **Undo**.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GitTarget } from "../../../lib/api";
import {
errText,
gitRewrite,
type ResetMode,
type ResetPlan,
type RewriteResult,
} from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
import { blockedReason } from "./RebaseDialog";
const MODES: ResetMode[] = ["soft", "mixed", "keep", "hard"];
export function ResetDialog({
target,
commit,
onClose,
onChanged,
}: {
target: GitTarget;
commit: { sha: string; short?: string; subject?: string };
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [plan, setPlan] = useState<ResetPlan | null>(null);
const [mode, setMode] = useState<ResetMode>("mixed");
const [force, setForce] = useState(false);
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RewriteResult | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
let live = true;
gitRewrite
.planReset(target, commit.sha)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, commit.sha]);
const branch = plan?.branch ?? "HEAD";
const blocked = plan ? blockedReason(t, plan, false) : null;
const hardBlocked = mode === "hard" && !!plan?.dirty;
const ready =
!!plan &&
!blocked &&
plan.related &&
plan.head !== plan.target &&
!(plan.pushed > 0 && !force) &&
!hardBlocked &&
(mode !== "hard" || typed === branch);
const run = async () => {
if (!plan) return;
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "reset", {
target: plan.target,
mode,
head: plan.head,
confirm: true,
...(plan.pushed ? { force: true } : {}),
...(mode === "hard" ? { confirmBranch: typed } : {}),
});
onChanged();
toast.success(t("reset.done", { short: commit.short ?? plan.target.slice(0, 7) }));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("reset.title", { branch, short: commit.short ?? commit.sha.slice(0, 7) })}
onClose={onClose}
testId="reset-dialog"
footer={
result ? (
<button type="button" className={BTN} onClick={onClose}>
{t("close")}
</button>
) : (
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="reset-run"
className={BTN_DANGER}
disabled={busy || !ready}
onClick={() => void run()}
>
{t("reset.run", { mode })}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : !plan ? (
error ? (
<ErrorLine error={error} />
) : (
<p className="text-fg-muted">{t("loading")}</p>
)
) : (
<>
{commit.subject && (
<p className="mb-1 truncate text-[11px] text-fg-primary" title={commit.subject}>
<span className="mr-1 font-mono text-fg-muted">{commit.short}</span>
{commit.subject}
</p>
)}
{blocked && <Warn>{blocked}</Warn>}
{!plan.related ? (
<Warn>{t("reset.unrelated")}</Warn>
) : plan.head === plan.target ? (
<p>{t("reset.already")}</p>
) : (
<p data-testid="reset-summary" className="mb-1.5">
{t("reset.summary", { dropped: plan.dropped, added: plan.added, branch })}
</p>
)}
<fieldset className="space-y-1">
<legend className="mb-1 text-[11px] font-medium text-fg-primary">
{t("reset.mode")}
</legend>
{MODES.map((m) => (
<label key={m} className="flex items-start gap-1.5 text-[11px]">
<input
type="radio"
name="reset-mode"
data-testid={`reset-mode-${m}`}
value={m}
checked={mode === m}
onChange={() => setMode(m)}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="font-medium text-fg-primary">{t(`reset.modes.${m}`)}</span>
<span className="block text-fg-muted">{t(`reset.modesHelp.${m}`)}</span>
</span>
</label>
))}
</fieldset>
{mode === "hard" &&
(plan.dirty ? (
<Warn testId="reset-hard-dirty">{t("reset.hardDirty")}</Warn>
) : (
<div className="mt-1.5">
<label className="mb-0.5 block text-[11px]" htmlFor="reset-confirm-branch">
{t("reset.typeBranch", { branch })}
</label>
<input
id="reset-confirm-branch"
data-testid="reset-confirm-branch"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
className="input w-full font-mono text-[11px]"
/>
</div>
))}
{plan.pushed > 0 && (
<Warn testId="reset-pushed">
{t("reset.pushed", { count: plan.pushed })}
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="reset-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
</Warn>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}
@@ -0,0 +1,90 @@
/**
* @file RewriteDone.tsx
* @description What a finished rebase / interactive rebase / reset leaves on
* screen: the result, a force-push reminder when the rewrite touched pushed
* commits, and the recovery button — **Undo** = `undo-rewrite` (`reset --keep`
* back to the recorded `before`), which the server runs only while HEAD is
* still the `after` this dialog saw (409 ESTALE otherwise).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Undo2 } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type RewriteResult } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, ErrorLine, Warn } from "./GitModal";
export function RewriteDone({
target,
result,
onChanged,
onUndone,
}: {
target: GitTarget;
result: RewriteResult;
onChanged: () => void;
onUndone: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const canUndo = !!result.before && !!result.after && result.before !== result.after;
const undo = async () => {
if (!isArmed("undo")) return arm("undo");
disarm();
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "undo-rewrite", {
to: result.before,
head: result.after,
confirm: true,
});
toast.success(t("undone"));
onChanged();
onUndone();
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<div data-testid="git-rewrite-done" className="space-y-1.5">
<p className="flex items-center gap-1.5 text-status-success">
<CheckCircle2 className="h-4 w-4 shrink-0" />
{t("doneTitle")}
</p>
{result.output && (
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded bg-surface-2 p-1.5 font-mono text-[10px] text-fg-muted">
{result.output}
</pre>
)}
{result.needsForcePush && <Warn>{t("needsForcePushAfter")}</Warn>}
{canUndo && (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
data-testid="git-rewrite-undo"
disabled={busy}
onClick={() => void undo()}
className={`${BTN} flex items-center gap-1`}
>
<Undo2 className="h-3 w-3" />
{isArmed("undo") ? t("undoConfirm") : t("undo")}
</button>
<span className="text-[11px] text-fg-muted">
{t("undoHint", { sha: result.before!.slice(0, 7) })}
</span>
</div>
)}
<ErrorLine error={error} />
</div>
);
}
@@ -0,0 +1,271 @@
/**
* @file The Tags tab of the git bar's Branches panel (`BranchManager`): the
* repo's tags (name, target commit, and for an annotated tag its message,
* tagger and date), a form that creates one — lightweight, or annotated when a
* message is typed — on HEAD or on a branch, tag or commit sha, and per tag:
* new branch from it, push it to origin (one tag, never `--tags`, never
* forced) and delete it locally, which is irreversible and so asks first. The
* server looks every name up (server/lib/git-refs.js); nothing here decides.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Plus, Tag, Trash2, Upload } from "lucide-react";
import type { GitTag } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
import { isSubmitEnter } from "../../../lib/keys";
import { timeAgo } from "../../../lib/format";
import { Checkbox } from "../../Checkbox";
import { smallBtn, smallInput, type RunRefAction } from "./refUi";
export function TagManager({
tags,
busy,
merging,
dirty,
run,
}: {
/** Already filtered by the panel's search box. */
tags: GitTag[];
busy: boolean;
merging: boolean;
dirty: number;
run: RunRefAction;
}) {
const { t } = useTranslation(["gitRefs"]);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [message, setMessage] = useState("");
const [from, setFrom] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [branchName, setBranchName] = useState<string | null>(null);
const [switchTo, setSwitchTo] = useState(false);
const create = async () => {
const tag = name.trim();
if (!tag) return;
const ok = await run(
"tag-create",
{
tag,
...(message.trim() ? { message: message.trim() } : {}),
...(from.trim() ? { from: from.trim() } : {}),
},
t("done.tagCreate", { tag })
);
if (ok) {
setName("");
setMessage("");
setFrom("");
setCreating(false);
}
};
const push = async (tag: string) => {
const ok = await confirmDialog({
title: t("tagPush"),
message: t("tagPushConfirm", { tag }),
confirmLabel: t("tagPush"),
destructive: false,
});
if (ok) await run("tag-push", { tag }, t("done.tagPush", { tag }));
};
const remove = async (tag: string) => {
const ok = await confirmDialog({
title: t("tagDelete"),
message: t("tagDeleteConfirm", { tag }),
confirmLabel: t("tagDelete"),
destructive: true,
});
if (ok && (await run("tag-delete", { tag, confirm: true }, t("done.tagDelete", { tag }))))
setSelected(null);
};
const branchFrom = (tag: string) => {
const branch = (branchName ?? "").trim();
if (!branch) return;
void run(
"branch-create",
{ branch, from: `refs/tags/${tag}`, ...(switchTo ? { switch: true } : {}) },
t("done.create", { branch })
).then((ok) => ok && setBranchName(null));
};
return (
<div className="space-y-2">
<div className="px-2">
{creating ? (
<div className="space-y-1.5 rounded border border-border-light bg-surface-0/40 p-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) void create();
}}
placeholder={t("tagName")}
aria-label={t("tagName")}
className={`${smallInput} w-full`}
/>
<input
value={from}
onChange={(e) => setFrom(e.target.value)}
placeholder={t("tagFrom")}
aria-label={t("tagFrom")}
className={`${smallInput} w-full`}
/>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
placeholder={t("tagMessage")}
aria-label={t("tagMessage")}
className={`${smallInput} w-full resize-y font-sans`}
/>
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
className={smallBtn}
disabled={busy || merging || !name.trim()}
onClick={() => void create()}
>
<Check className="h-3 w-3" />
{message.trim() ? t("tagCreateAnnotated") : t("tagCreateLight")}
</button>
<button type="button" className={smallBtn} onClick={() => setCreating(false)}>
{t("cancel")}
</button>
</div>
</div>
) : (
<button
type="button"
className={smallBtn}
disabled={busy || merging}
onClick={() => setCreating(true)}
>
<Plus className="h-3 w-3" />
{t("tagNew")}
</button>
)}
</div>
{tags.length === 0 ? (
<p className="px-2 text-xs text-fg-muted">{t("noTags")}</p>
) : (
<ul>
{tags.map((tag) => {
const open = selected === tag.name;
return (
<li key={tag.name} className={open ? "rounded bg-surface-2" : undefined}>
<button
type="button"
data-ref-row
aria-expanded={open}
onClick={() => {
setSelected(open ? null : tag.name);
setBranchName(null);
}}
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
>
<Tag className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span title={tag.name} className="truncate font-mono text-xs text-fg-primary">
{tag.name}
</span>
{tag.sha && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
{tag.sha.slice(0, 7)}
</span>
)}
{tag.annotated && (
<span className="shrink-0 rounded border border-border-light px-1 text-[9px] uppercase text-fg-muted">
{t("annotated")}
</span>
)}
</span>
<span className="block truncate text-[10px] text-fg-muted">
{[tag.message, tag.tagger, tag.date ? timeAgo(tag.date) : null]
.filter(Boolean)
.join(" · ")}
</span>
</span>
</button>
{open && (
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
<div className="flex flex-wrap gap-1">
<button
type="button"
className={smallBtn}
disabled={busy || !tag.sha}
onClick={() => {
setBranchName((v) => (v === null ? "" : null));
setSwitchTo(false);
}}
>
<Plus className="h-3 w-3" />
{t("newFrom")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || merging}
title={t("tagPushHint")}
onClick={() => void push(tag.name)}
>
<Upload className="h-3 w-3" />
{t("tagPush")}
</button>
<button
type="button"
className={`${smallBtn} hover:text-status-danger`}
disabled={busy || merging}
title={t("tagDeleteHint")}
onClick={() => void remove(tag.name)}
>
<Trash2 className="h-3 w-3" />
{t("tagDelete")}
</button>
</div>
{branchName !== null && (
<div className="flex flex-wrap items-center gap-1.5">
<input
autoFocus
value={branchName}
onChange={(e) => setBranchName(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) branchFrom(tag.name);
}}
placeholder={t("newBranchName")}
aria-label={t("newBranchName")}
className={smallInput}
/>
<Checkbox
checked={switchTo}
onChange={setSwitchTo}
disabled={dirty > 0 || merging}
label={t("switchToIt")}
/>
<button
type="button"
className={smallBtn}
disabled={busy || !branchName.trim()}
onClick={() => branchFrom(tag.name)}
>
<Plus className="h-3 w-3" />
{t("create")}
</button>
</div>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
export default TagManager;
@@ -0,0 +1,320 @@
/**
* @file Tests for the Changes panel's history tools: the 3-way merge editor
* (three panes, per-block accept/both/edit, "N left", Save only when every
* block is chosen and it sends the composed result with the opened sha), the
* rebase banner's Skip, the interactive-rebase list (pushed warning + tick,
* reorder, squash/reword/drop in the todo, two-click run, Undo after) and the
* reset dialog (mode, hard needs a clean tree and the typed branch name).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MergeEditor, composeResult } from "../MergeEditor";
import { InteractiveRebase, todoProblem } from "../InteractiveRebase";
import { ResetDialog } from "../ResetDialog";
import { RebaseSkipButton } from "../GitTools";
import { gitRewrite } from "../../../../lib/gitRewrite";
import { parseConflicts } from "../../../../lib/conflicts";
vi.mock("../../../../lib/gitRewrite", async (orig) => {
const real = await orig<typeof import("../../../../lib/gitRewrite")>();
return {
...real,
gitRewrite: {
conflict: vi.fn(),
planInteractive: vi.fn(),
planReset: vi.fn(),
run: vi.fn(),
},
};
});
vi.mock("../../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const WORKING = [
"top",
"<<<<<<< HEAD",
"mine",
"=======",
"theirs",
">>>>>>> feat",
"middle",
"<<<<<<< HEAD",
"a",
"=======",
"b",
">>>>>>> feat",
"",
].join("\n");
const HEAD = "a".repeat(40);
const commit = (n: number, pushed = false) => ({
sha: String(n).repeat(40),
short: String(n).repeat(7),
subject: `c${n}`,
message: `c${n}`,
pushed,
});
beforeEach(() => {
vi.mocked(gitRewrite.conflict)
.mockReset()
.mockResolvedValue({
available: true,
path: "a.txt",
operation: "merge",
branch: "main",
incoming: { sha: "f".repeat(40), short: "fffffff", subject: "feat work" },
base: "top\nbase\n",
ours: "top\nmine\n",
theirs: "top\ntheirs\n",
working: WORKING,
sha: "s1",
usable: true,
binary: false,
tooBig: false,
});
vi.mocked(gitRewrite.run).mockReset().mockResolvedValue({ action: "x", ok: true, output: "" });
});
describe("MergeEditor", () => {
it("shows three panes and saves the composed result once every block is chosen", async () => {
const user = userEvent.setup();
const onResolved = vi.fn();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={onResolved} />);
const incoming = await screen.findByTestId("merge-incoming");
expect(incoming).toHaveTextContent("Incoming: fffffff feat work");
expect(incoming).toHaveTextContent("theirs");
expect(screen.getByTestId("merge-current")).toHaveTextContent("Current: main");
expect(screen.getByTestId("merge-left")).toHaveTextContent("2 of 2 conflicts left");
const save = screen.getByTestId("merge-save");
expect(save).toBeDisabled();
const [first, second] = screen.getAllByTestId("merge-block") as [HTMLElement, HTMLElement];
await user.click(within(first).getByTestId("merge-accept-incoming"));
expect(screen.getByTestId("merge-left")).toHaveTextContent("1 of 2 conflicts left");
await user.click(within(second).getByTestId("merge-both-ic"));
expect(screen.getByTestId("merge-left")).toHaveTextContent("Every conflict has a choice");
await user.click(save);
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "conflict-save", {
path: "a.txt",
content: "top\ntheirs\nmiddle\nb\na\n",
baseSha: "s1",
})
);
expect(onResolved).toHaveBeenCalled();
});
it("edits one block by hand, and the base pane is one click away", async () => {
const user = userEvent.setup();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
const [first] = (await screen.findAllByTestId("merge-block")) as [HTMLElement];
await user.click(within(first).getByTestId("merge-edit-block"));
const box = within(first).getByTestId("merge-block-text");
await user.clear(box);
await user.type(box, "hand");
await user.click(within(first).getByRole("button", { name: "Done" }));
expect(first).toHaveTextContent("hand");
expect(screen.queryByTestId("merge-base")).toBeNull();
await user.click(screen.getByLabelText("Show base"));
expect(screen.getByTestId("merge-base")).toHaveTextContent("base");
});
it("labels the sides for a rebase and explains a refused save", async () => {
vi.mocked(gitRewrite.conflict).mockResolvedValueOnce({
...(await vi.mocked(gitRewrite.conflict).getMockImplementation()!(3, "a.txt")),
operation: "rebase",
});
vi.mocked(gitRewrite.run).mockRejectedValueOnce(
Object.assign(new Error("x"), { code: "EMARKERS" })
);
const user = userEvent.setup();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
expect(await screen.findByTestId("merge-current")).toHaveTextContent(
"the branch you are rebasing onto"
);
await user.click(screen.getByTestId("merge-whole-toggle"));
await user.click(screen.getByTestId("merge-save"));
expect(await screen.findByRole("alert")).toHaveTextContent("still has conflict markers");
});
it("composeResult keeps markers on an unresolved block", () => {
const parts = parseConflicts(WORKING.split("\n"))!;
expect(composeResult(parts, [{ kind: "ours" }])).toBe(
"top\nmine\nmiddle\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> feat\n"
);
});
});
describe("RebaseSkipButton", () => {
it("skips the stopped commit and re-reads", async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
render(<RebaseSkipButton target={3} onChanged={onChanged} />);
await user.click(screen.getByTestId("rebase-skip"));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-skip", {});
});
});
describe("InteractiveRebase", () => {
beforeEach(() => {
vi.mocked(gitRewrite.planInteractive)
.mockReset()
.mockResolvedValue({
available: true,
kind: "interactive",
head: HEAD,
branch: "feat",
subdir: false,
operation: null,
dirty: false,
base: "b".repeat(40),
merges: false,
commits: [commit(1, true), commit(2), commit(3)],
pushed: 1,
});
});
it("warns about pushed commits, needs the tick, and sends the edited todo", async () => {
const user = userEvent.setup();
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
action: "rebase-interactive",
ok: true,
output: "",
before: HEAD,
after: "c".repeat(40),
conflicts: [],
stopped: false,
needsForcePush: true,
});
render(<InteractiveRebase target={3} onClose={vi.fn()} onChanged={vi.fn()} />);
expect(await screen.findByTestId("interactive-summary")).toHaveTextContent(
"This rewrites 3 commits; 1 are already pushed, so a force-push will be needed."
);
const run = screen.getByTestId("interactive-run");
expect(run).toBeDisabled(); // unchanged + pushed not acknowledged
const rows = screen.getAllByTestId("interactive-row") as HTMLElement[];
await user.selectOptions(within(rows[1]!).getByTestId("interactive-action"), "squash");
await user.selectOptions(within(rows[2]!).getByTestId("interactive-action"), "reword");
const msg = within(rows[2]!).getByTestId("interactive-message");
await user.clear(msg);
await user.type(msg, "new words");
await user.click(within(rows[2]!).getByRole("button", { name: "Move up" }));
expect(run).toBeDisabled();
await user.click(screen.getByTestId("interactive-force"));
expect(run).toBeEnabled();
await user.click(run);
expect(gitRewrite.run).not.toHaveBeenCalled(); // armed only
await user.click(screen.getByRole("button", { name: "Click again to rewrite" }));
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-interactive", {
head: HEAD,
confirm: true,
force: true,
todo: [
{ sha: commit(1).sha, action: "pick" },
{ sha: commit(3).sha, action: "reword", message: "new words" },
{ sha: commit(2).sha, action: "squash" },
],
})
);
expect(await screen.findByTestId("git-rewrite-done")).toHaveTextContent(
"Force push (with lease)"
);
expect(screen.getByTestId("git-rewrite-undo")).toBeInTheDocument();
});
it("todoProblem refuses a squash with nothing above and an empty reword", () => {
const c = commit(1);
expect(todoProblem([{ commit: c, action: "squash", message: "" }])).toBe("first");
expect(todoProblem([{ commit: c, action: "drop", message: "" }])).toBe("allDropped");
expect(todoProblem([{ commit: c, action: "reword", message: " " }])).toBe("reword");
expect(todoProblem([{ commit: c, action: "pick", message: "" }])).toBeNull();
});
});
describe("ResetDialog", () => {
const plan = (over = {}) => ({
available: true,
kind: "reset" as const,
head: HEAD,
branch: "feat",
subdir: false,
operation: null,
dirty: false,
target: "d".repeat(40),
related: true,
dropped: 2,
added: 0,
pushed: 0,
...over,
});
it("hard needs the branch name typed back", async () => {
vi.mocked(gitRewrite.planReset).mockReset().mockResolvedValue(plan());
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
action: "reset",
ok: true,
output: "",
before: HEAD,
after: "d".repeat(40),
});
const user = userEvent.setup();
render(
<ResetDialog
target={3}
commit={{ sha: "d".repeat(40), short: "ddddddd", subject: "old" }}
onClose={vi.fn()}
onChanged={vi.fn()}
/>
);
expect(await screen.findByTestId("reset-summary")).toHaveTextContent("2 commit(s) leave feat");
await user.click(screen.getByTestId("reset-mode-hard"));
const run = screen.getByTestId("reset-run");
expect(run).toBeDisabled();
await user.type(screen.getByTestId("reset-confirm-branch"), "feat");
expect(run).toBeEnabled();
await user.click(run);
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "reset", {
target: "d".repeat(40),
mode: "hard",
head: HEAD,
confirm: true,
confirmBranch: "feat",
})
);
expect(await screen.findByTestId("git-rewrite-undo")).toBeInTheDocument();
});
it("refuses hard on a dirty tree and asks for the tick on pushed commits", async () => {
vi.mocked(gitRewrite.planReset)
.mockReset()
.mockResolvedValue(plan({ dirty: true, pushed: 1 }));
const user = userEvent.setup();
render(
<ResetDialog
target={3}
commit={{ sha: "d".repeat(40) }}
onClose={vi.fn()}
onChanged={vi.fn()}
/>
);
await screen.findByTestId("reset-pushed");
const run = screen.getByTestId("reset-run");
expect(run).toBeDisabled();
await user.click(screen.getByTestId("reset-force"));
expect(run).toBeEnabled(); // mixed works on a dirty tree
await user.click(screen.getByTestId("reset-mode-hard"));
expect(screen.getByTestId("reset-hard-dirty")).toBeInTheDocument();
expect(run).toBeDisabled();
});
});
@@ -0,0 +1,308 @@
/**
* @file Pins the History tab's graph view: rows with their graph cell and ref
* badges, the search box turning `author:`/`path:` into the log's filters,
* all branches, multi-select (checkbox and Shift-click) into a cherry-pick
* preview that lists the commits in the server's order and runs only on
* Confirm, a paused result handed to the overview, and Undo after a clean
* pick (asked first, sent with the from/to the pick answered). Also the pure
* `parseSearch` helper, and the commit menu's rewrite tools (Reset branch to
* here, Edit commits after this one) and Create tag here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HistoryView } from "../HistoryView";
import { api } from "../../../../lib/api";
import { confirmDialog } from "../../../../lib/confirm";
import { parseSearch } from "../../../../lib/gitHistory";
import type { LaneCommit } from "../../../../lib/types";
vi.mock("../../../../lib/confirm", () => ({
confirmDialog: vi.fn(async () => true),
}));
vi.mock("../../../../lib/api", () => ({
api: {
lanes: {
gitLog: vi.fn(),
changes: vi.fn(),
gitRefAction: vi.fn(),
gitHistory: {
commit: vi.fn(),
patch: vi.fn(),
pickPlan: vi.fn(),
pick: vi.fn(),
act: vi.fn(),
compare: vi.fn(),
treeAt: vi.fn(),
},
},
},
}));
const sha = (c: string) => c.repeat(40);
const commit = (c: string, over: Partial<LaneCommit> = {}): LaneCommit => ({
sha: sha(c),
short: c.repeat(7),
subject: `commit ${c}`,
author: "Ann",
date: "2026-10-01T00:00:00Z",
refs: [],
parents: [],
badges: [],
...over,
});
// other: c -> b (feature branch), main: a (HEAD)
const LOG = [
commit("c", { parents: [sha("b")], badges: [{ kind: "local", name: "feature" }] }),
commit("b", { parents: [sha("a")] }),
commit("a", {
parents: [],
badges: [
{ kind: "local", name: "main", current: true },
{ kind: "tag", name: "v1" },
],
}),
];
const props = () => ({
target: 7,
branch: "main",
branches: ["main", "feature"],
historyRef: null,
onHistoryRef: vi.fn(),
reread: 0,
disabled: false,
headPushed: false,
onViewCommit: vi.fn(),
onOpenFileAtCommit: vi.fn(),
onUndoCommit: vi.fn(),
onResetHere: vi.fn(),
onEditAfter: vi.fn(),
onPaused: vi.fn(),
onChanged: vi.fn(),
});
beforeEach(() => {
vi.mocked(api.lanes.gitLog).mockReset().mockResolvedValue({ available: true, commits: LOG });
vi.mocked(api.lanes.changes)
.mockReset()
.mockResolvedValue({
available: true,
range: "commit",
base: null,
baseRef: null,
sha: sha("c"),
context: 3,
truncated: false,
files: [],
});
vi.mocked(api.lanes.gitHistory.commit).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.gitHistory.pick).mockReset();
vi.mocked(api.lanes.gitHistory.act).mockReset();
vi.mocked(api.lanes.gitHistory.pickPlan)
.mockReset()
.mockImplementation(async (_t, op, shas) => ({
available: true,
op,
// The server's order: oldest first for a pick.
commits: [...shas].reverse().map((s) => ({
sha: s,
short: s.slice(0, 7),
subject: `commit ${s[0]}`,
author: "Ann",
date: "",
})),
branch: "main",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(confirmDialog).mockClear();
});
describe("parseSearch", () => {
it("splits author: and path: from the message text", () => {
expect(parseSearch('fix login author:ann path:"src/a b.ts"')).toEqual({
q: "fix login",
author: "ann",
path: "src/a b.ts",
});
expect(parseSearch(" ")).toEqual({});
});
});
describe("HistoryView", () => {
it("draws one graph cell per commit with its ref badges", async () => {
render(<HistoryView {...props()} />);
await screen.findByText("commit c");
expect(screen.getAllByTestId("lane-changes-graph")).toHaveLength(3);
const badges = screen.getAllByTestId("ref-badge");
expect(badges.map((b) => b.textContent)).toEqual(["feature", "main", "v1"]);
expect(badges[1]).toHaveAttribute("title", expect.stringMatching(/branch you are on/));
});
it("searches with filters and reads every branch", async () => {
const user = userEvent.setup();
render(<HistoryView {...props()} />);
await screen.findByText("commit c");
await user.type(screen.getByTestId("history-search"), "fix author:ann");
await waitFor(
() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 100,
q: "fix",
author: "ann",
}),
{ timeout: 2000 }
);
await user.click(screen.getByRole("checkbox", { name: "All branches" }));
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 100,
all: true,
q: "fix",
author: "ann",
})
);
});
it("multi-selects, previews the cherry-pick in order, and runs it only on Confirm", async () => {
const user = userEvent.setup();
const p = { ...props(), historyRef: "feature" };
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
action: "cherry-pick",
ok: true,
output: "",
conflicts: [],
paused: false,
from: sha("a"),
to: sha("e"),
commits: [
{ sha: sha("d"), short: "ddddddd", subject: "commit b" },
{ sha: sha("e"), short: "eeeeeee", subject: "commit c" },
],
});
vi.mocked(api.lanes.gitHistory.act).mockResolvedValue({
action: "undo-pick",
ok: true,
output: "",
conflicts: [],
paused: false,
});
render(<HistoryView {...p} />);
await screen.findByText("commit c");
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
// Shift-click extends from the anchor (c) to b.
fireEvent.click(screen.getByText("commit b"), { shiftKey: true });
expect(screen.getByTestId("history-selection")).toHaveTextContent("2 commits selected");
await user.click(screen.getByTestId("history-pick-selected"));
const plan = await screen.findByTestId("pick-plan");
expect(
within(plan)
.getAllByRole("listitem")
.map((li) => li.textContent)
).toEqual(["1.bbbbbbbcommit b", "2.ccccccccommit c"]);
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent("clean");
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
await user.click(screen.getByTestId("pick-confirm"));
await waitFor(() =>
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [sha("b"), sha("c")])
);
expect(p.onChanged).toHaveBeenCalled();
// Undo is offered, asked first, and sent with what the pick answered.
await user.click(await screen.findByTestId("pick-undo"));
expect(confirmDialog).toHaveBeenCalled();
await waitFor(() =>
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "undo-pick", {
from: sha("a"),
to: sha("e"),
confirm: true,
})
);
await waitFor(() => expect(screen.queryByTestId("pick-result")).toBeNull());
});
it("refuses to start when the tree is dirty, and hands a paused pick to the overview", async () => {
const user = userEvent.setup();
const p = { ...props(), historyRef: "feature" };
vi.mocked(api.lanes.gitHistory.pickPlan).mockResolvedValueOnce({
available: true,
op: "cherry-pick",
commits: [{ sha: sha("c"), short: "ccccccc", subject: "commit c", author: "", date: "" }],
branch: "main",
clean: false,
repoRoot: true,
busy: false,
});
render(<HistoryView {...p} />);
await screen.findByText("commit c");
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
await user.click(screen.getByTestId("history-pick-selected"));
await screen.findByTestId("pick-plan");
expect(screen.getByTestId("pick-confirm")).toBeDisabled();
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent(/uncommitted changes/);
await user.click(screen.getByRole("button", { name: "Cancel" }));
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
action: "cherry-pick",
ok: true,
output: "",
conflicts: ["a.txt"],
paused: true,
});
await user.click(screen.getByTestId("history-pick-selected"));
await user.click(await screen.findByTestId("pick-confirm"));
await waitFor(() => expect(p.onPaused).toHaveBeenCalled());
expect(screen.queryByTestId("pick-result")).toBeNull();
});
it("opens the details pane on a click and the full change on a double-click", async () => {
const user = userEvent.setup();
const p = props();
render(<HistoryView {...p} />);
await user.click(await screen.findByText("commit b"));
expect(await screen.findByTestId("commit-details")).toBeInTheDocument();
expect(api.lanes.gitHistory.commit).toHaveBeenCalledWith(7, sha("b"));
await user.dblClick(screen.getByText("commit c"));
expect(p.onViewCommit).toHaveBeenCalledWith(sha("c"));
});
it("keeps the rewrite tools in the commit menu and creates a tag at a commit", async () => {
const user = userEvent.setup();
const p = props();
vi.mocked(api.lanes.gitRefAction).mockReset().mockResolvedValue({
action: "tag-create",
ok: true,
output: "",
});
render(<HistoryView {...p} />);
const menuOn = async (text: string) => {
await user.pointer({ keys: "[MouseRight]", target: await screen.findByText(text) });
return screen.getByTestId("lane-changes-commit-menu");
};
// HEAD's own commit (main, the current branch): nothing after it to edit.
expect(
within(await menuOn("commit a")).getByTestId("lane-changes-menu-interactive")
).toBeDisabled();
await user.keyboard("{Escape}");
await user.click(within(await menuOn("commit b")).getByText("Reset branch to here…"));
expect(p.onResetHere).toHaveBeenCalledWith(expect.objectContaining({ sha: sha("b") }));
await user.click(within(await menuOn("commit b")).getByText("Edit commits after this one…"));
expect(p.onEditAfter).toHaveBeenCalledWith(sha("b"));
await user.click(within(await menuOn("commit a")).getByText("Create tag here…"));
await user.type(screen.getByTestId("create-ref-name"), "v2");
await user.click(screen.getByTestId("create-ref-confirm"));
await waitFor(() =>
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(7, "tag-create", {
tag: "v2",
from: sha("a"),
})
);
expect(p.onChanged).toHaveBeenCalled();
});
});
@@ -0,0 +1,88 @@
/**
* @file Pins the paused cherry-pick/revert banner: it names the commit it
* stopped at and the step ("2 of 3"), Continue waits for every conflict to be
* resolved, Skip and Abort each need a second click and only then send
* `confirm: true`, and it draws nothing when no pick is open.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PickBanner } from "../PickBanner";
import { api } from "../../../../lib/api";
vi.mock("../../../../lib/api", () => ({
api: { lanes: { gitHistory: { sequence: vi.fn(), act: vi.fn() } } },
}));
const paused = (conflicts: string[]) => ({
available: true as const,
active: true as const,
operation: "cherry-pick" as const,
current: { sha: "b".repeat(40), short: "bbbbbbb", subject: "edit a" },
remaining: [{ sha: "c".repeat(40), short: "ccccccc", subject: "add c" }],
done: 1,
startedFrom: "a".repeat(40),
conflicts,
});
const props = () => ({
target: 7,
conflicts: 1,
reread: 0,
busy: false,
onBusy: vi.fn(),
onDone: vi.fn(),
});
beforeEach(() => {
vi.mocked(api.lanes.gitHistory.act).mockReset().mockResolvedValue({
action: "pick-continue",
ok: true,
output: "",
conflicts: [],
paused: false,
commits: [],
});
});
describe("PickBanner", () => {
it("names the stop and its step, and Continue waits for the conflicts", async () => {
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused(["a.txt"]));
render(<PickBanner {...props()} />);
const banner = await screen.findByTestId("pick-banner");
expect(banner).toHaveTextContent('cherry-pick paused at bbbbbbb "edit a" (2 of 3)');
expect(banner).toHaveTextContent("Still to apply: ccccccc");
expect(screen.getByTestId("pick-continue")).toBeDisabled();
});
it("continues once resolved, and skips/aborts only on a second click", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused([]));
const p = props();
render(<PickBanner {...p} />);
await user.click(await screen.findByTestId("pick-continue"));
await waitFor(() =>
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "pick-continue", {})
);
expect(p.onDone).toHaveBeenCalled();
await user.click(screen.getByTestId("pick-skip"));
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId("pick-skip"));
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-skip", { confirm: true });
await user.click(screen.getByTestId("pick-abort"));
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(2);
await user.click(screen.getByTestId("pick-abort"));
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-abort", { confirm: true });
});
it("draws nothing when no pick is open", async () => {
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue({ available: true, active: false });
const { container } = render(<PickBanner {...props()} />);
await waitFor(() => expect(api.lanes.gitHistory.sequence).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @file Shared bits of the Branches panel (`BranchManager`, `TagManager`): the
* small button and input classes, the write runner's type, and the arrow-key
* walk over a list's rows.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { KeyboardEvent } from "react";
import type { GitRefAction, GitRefBody } from "../../../lib/api";
/** Runs one write; resolves true when it succeeded (the list is then re-read). */
export type RunRefAction = (
action: GitRefAction,
body: GitRefBody,
done?: string
) => Promise<boolean>;
export const smallBtn =
"inline-flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
export const smallInput =
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
/** Arrow-key walk over the rows of a list (`[data-ref-row]`), Home/End too. */
export function onListKeys(e: KeyboardEvent<HTMLElement>) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
// Inside a text field the arrows and Home/End move the caret.
if ((e.target as HTMLElement).closest("input, textarea, select")) return;
const rows = [...e.currentTarget.querySelectorAll<HTMLElement>("[data-ref-row]")];
if (!rows.length) return;
const at = rows.indexOf(document.activeElement as HTMLElement);
const next =
e.key === "Home"
? 0
: e.key === "End"
? rows.length - 1
: e.key === "ArrowDown"
? Math.min(rows.length - 1, at + 1)
: Math.max(0, at - 1);
e.preventDefault();
rows[next]?.focus();
}
+46 -9
View File
@@ -13,7 +13,8 @@
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
* and Manage opens the Claude config page on that tab.
* and Manage opens the Claude config page on that tab. The menu opens
* downward when the box sits too near the top to fit it above.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
}
const PANEL =
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
"absolute z-40 w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
/** Room the menu and its tallest submenu (Project files: search + list) need. */
const MENU_ROOM = 380;
/** The visible band the button sits in: the viewport, narrowed by any
* scrolling/clipping ancestor (the page body scrolls inside the layout). */
function clipBand(el: HTMLElement): { top: number; bottom: number } {
let top = 0;
let bottom = window.innerHeight;
for (let p = el.parentElement; p; p = p.parentElement) {
const o = getComputedStyle(p);
if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) {
const r = p.getBoundingClientRect();
top = Math.max(top, r.top);
bottom = Math.min(bottom, r.bottom);
}
}
return { top, bottom };
}
/** Open downward when there is not room above but more below. */
function opensDown(el: HTMLElement | null): boolean {
if (!el) return false;
const r = el.getBoundingClientRect();
const band = clipBand(el);
const above = r.top - band.top;
const below = band.bottom - r.bottom;
return above < MENU_ROOM && below > above;
}
const ROW =
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
@@ -195,6 +225,7 @@ export function ChatAddMenu({
const { t } = useTranslation("run");
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [down, setDown] = useState(false);
const [sub, setSub] = useState<Sub | null>(null);
const [context, setContext] = useState<Attachment[] | null>(null);
const [recent, setRecent] = useState<Attachment[]>([]);
@@ -286,6 +317,8 @@ export function ChatAddMenu({
/>
))
);
// A submenu lines up with the menu's edge nearest the button.
const edge = down ? "top-0" : "bottom-0";
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
return (
@@ -296,13 +329,17 @@ export function ChatAddMenu({
aria-label={t("chat.add")}
title={t("chat.add")}
aria-expanded={open}
onClick={() => (open ? close() : setOpen(true))}
onClick={() => {
if (open) return close();
setDown(opensDown(ref.current));
setOpen(true);
}}
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Plus className="h-4 w-4" />
</button>
{open && (
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} left-0 ${down ? "top-full mt-2" : "bottom-full mb-2"}`}>
<Row
icon={<Paperclip className="h-4 w-4" />}
label={t("chat.upload")}
@@ -376,13 +413,13 @@ export function ChatAddMenu({
)}
{sub === "project" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1 w-72 pt-2`}>
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
</div>
)}
{(sub === "context" || sub === "recent") && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{(sub === "context" ? context : recent)?.length ? (
(sub === "context" ? context! : recent).map((it) => (
@@ -405,7 +442,7 @@ export function ChatAddMenu({
)}
{sub === "skills" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
<div className="my-1 border-t border-border" />
<Row
@@ -417,7 +454,7 @@ export function ChatAddMenu({
)}
{sub === "plugins" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{lists && lists.plugins.length === 0 && (
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
@@ -443,7 +480,7 @@ export function ChatAddMenu({
onClick={() => manage("plugins")}
/>
{shownPlugin && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{entries(
shownPlugin.items,
+4 -1
View File
@@ -689,8 +689,11 @@ export const PaneChat = memo(function PaneChat({
// another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended.
intervalMs: working || waiting ? 5000 : undefined,
// The transcript growing re-reads it too: a hook that reached another
// dashboard on the same database sends this one no agent_updated, and
// without a poll Stop never showed for a turn the chat calls Thinking….
match: (msg) =>
msg.type === "agent_updated" &&
(msg.type === "agent_updated" || msg.type === "transcript_changed") &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
+3
View File
@@ -118,6 +118,9 @@ export const i18nReady = i18n
"admin",
"onboarding",
"artifacts",
"gitRefs",
"gitTools",
"gitHistory",
],
defaultNS: "common",
interpolation: { escapeValue: false },
+144
View File
@@ -0,0 +1,144 @@
{
"close": "Close",
"cancel": "Cancel",
"dismiss": "Dismiss",
"compare": "Compare",
"commit": "Commit",
"blame": "Blame",
"binary": "binary",
"running": "Working…",
"op.cherry-pick": "cherry-pick",
"op.revert": "revert",
"badge.local": "Branch {{name}}",
"badge.remote": "Remote branch {{name}}",
"badge.tag": "Tag {{name}}",
"badge.head": "HEAD (detached)",
"badge.current": "{{name}} — the branch you are on",
"searchPlaceholder": "Search message or SHA — author:name path:file",
"searchLabel": "Search the history",
"clearSearch": "Clear the search",
"allBranches": "All branches",
"noMatches": "No commit matches this search",
"selectCommit": "Select {{short}}",
"selectedN_one": "{{count}} commit selected",
"selectedN_other": "{{count}} commits selected",
"clearSelection": "Clear",
"mixedSelection": "Some of these are on this branch and some are not — select only one kind",
"mergeCommitRefused": "A merge commit can't be cherry-picked or reverted here — do it in a terminal",
"pickN_one": "Cherry-pick {{count}} commit onto {{branch}}",
"pickN_other": "Cherry-pick {{count}} commits onto {{branch}}",
"revertN_one": "Revert {{count}} commit",
"revertN_other": "Revert {{count}} commits",
"pickedN_one": "Cherry-picked {{count}} commit onto {{branch}}.",
"pickedN_other": "Cherry-picked {{count}} commits onto {{branch}}.",
"revertedN_one": "Reverted {{count}} commit on {{branch}}.",
"revertedN_other": "Reverted {{count}} commits on {{branch}}.",
"pausedConflicts_one": "The {{op}} stopped on {{count}} conflicted file — resolve it in Changes, then Continue",
"pausedConflicts_other": "The {{op}} stopped on {{count}} conflicted files — resolve them in Changes, then Continue",
"pausedEmpty": "The {{op}} stopped: a commit's changes are already on this branch — Skip it or Abort",
"undo": "Undo",
"undoHint": "Take these new commits back off the branch (only while they are not pushed)",
"undoTitle": "Undo this {{op}}?",
"undoBody_one": "The {{count}} new commit is removed from {{branch}}. Its changes stay staged, so nothing is lost. Refused if it was already pushed or the branch moved on.",
"undoBody_other": "The {{count}} new commits are removed from {{branch}}. Their changes stay staged, so nothing is lost. Refused if they were already pushed or the branch moved on.",
"undone_one": "Undone — {{count}} commit taken back, its changes are staged",
"undone_other": "Undone — {{count}} commits taken back, their changes are staged",
"showDetails": "Show details",
"browseFiles": "Browse files at this commit",
"compareWithCurrent": "Compare with current",
"copyPatch": "Copy patch",
"noPatch": "No patch for this commit",
"patchOmitted_one": "{{count}} file holding secrets was left out of the patch",
"patchOmitted_other": "{{count}} files holding secrets were left out of the patch",
"createBranchHere": "Create branch here…",
"detailsTitle": "Commit details",
"noDetails": "Could not read this commit",
"author": "Author",
"committer": "Committer",
"parents_one": "Parent",
"parents_other": "Parents",
"rootCommit": "none (first commit)",
"refs": "Refs",
"containedIn": "In",
"viewAllChanges": "View all changes",
"filesChanged_one": "{{count}} file changed",
"filesChanged_other": "{{count}} files changed",
"planning": "Working out the order…",
"planUnavailable": "This folder is not a git repository",
"pickTitle_one": "Cherry-pick {{count}} commit onto {{branch}}?",
"pickTitle_other": "Cherry-pick {{count}} commits onto {{branch}}?",
"revertTitle_one": "Revert {{count}} commit?",
"revertTitle_other": "Revert {{count}} commits?",
"pickIntro_one": "This commit is applied to {{branch}} as a new commit:",
"pickIntro_other": "These commits are applied to {{branch}} one by one, in this order, each as a new commit:",
"revertIntro_one": "A new commit that undoes this one is added to {{branch}}:",
"revertIntro_other": "A new commit undoing each of these is added to {{branch}}, newest first, in this order:",
"treeClean": "The working tree is clean.",
"blockDirty": "The working tree has uncommitted changes. Commit or discard them first, so an Abort can never take them with it.",
"blockSubdir": "This workspace is a subfolder of its repository; a cherry-pick or revert changes the whole repository, so run it from a workspace at the repo root.",
"blockBusy": "A merge, cherry-pick, revert or rebase is already in progress. Finish or abort it first.",
"pickResult": "Files in this workspace change right away. The original commits are not touched.",
"revertResult": "History is not rewritten: the reverted commits stay, and new commits undo them.",
"onConflict": "If a commit conflicts, the {{op}} pauses there: resolve the files in Changes, then Continue — or Skip that commit, or Abort to go back to exactly where you are now.",
"pausedAt": "{{op}} paused at {{short}} \"{{subject}}\" ({{step}} of {{total}}):",
"pausedBetween": "{{op}} paused ({{step}} of {{total}}):",
"pausedHelpConflicts_one": "resolve the conflicted file, then Continue — or Skip this commit, or Abort to go back to where you were.",
"pausedHelpConflicts_other": "resolve the {{count}} conflicted files, then Continue — or Skip this commit, or Abort to go back to where you were.",
"pausedHelpReady": "every conflict is resolved — Continue to commit it and go on, or Skip this commit, or Abort to go back to where you were.",
"pausedHelpResume": "Continue with the remaining commits, or Abort to go back to where you were.",
"stillToApply_one": "Still to apply: {{list}}",
"stillToApply_other": "Still to apply ({{count}}): {{list}}",
"continue": "Continue",
"continueBlocked": "Resolve every conflicted file first",
"skip": "Skip this commit",
"skipHint": "Drop this commit (and any resolution made for it) and go on with the next",
"skipConfirm": "Click again to skip",
"abort": "Abort",
"abortHint": "Stop and put the branch back exactly where it was before",
"abortConfirm": "Click again to abort the {{op}}",
"aborted": "{{op}} aborted — the branch is back where it was",
"sequenceDone_one": "{{op}} finished",
"sequenceDone_other": "{{op}} finished",
"status.A": "Added",
"status.M": "Modified",
"status.D": "Deleted",
"status.R": "Renamed",
"status.C": "Copied",
"renamedFrom": "renamed from {{from}}",
"noBlame": "No history to blame for this file",
"blameTitle": "Blame {{path}}",
"workingTree": "working tree",
"blameBefore": "Blame the version before this change",
"notCommitted": "not committed yet",
"browseTitle": "Files at {{short}}",
"pickAFile": "Pick a file on the left",
"binaryFile": "Binary file — not shown",
"compareTitle": "Compare",
"compareUnavailable": "This folder is not a git repository",
"compareA": "From (branch, tag or commit)",
"compareB": "To (branch, tag or commit)",
"swap": "Swap the two sides",
"modeThree": "Changes since they diverged (A...B)",
"modeTwo": "Difference between the two (A B)",
"tabFiles_one": "{{count}} file",
"tabFiles_other": "{{count}} files",
"tabOnly_one": "Only in {{name}} ({{count}})",
"tabOnly_other": "Only in {{name}} ({{count}})",
"noDifference": "No file differs",
"noCommitsSide": "No commit only on this side",
"backToFiles": "Back to the files",
"secretFile": "This file holds secrets and is not shown",
"noTextChange": "No text change to show (binary, or a mode change)",
"createBranchTitle": "New branch at {{short}}",
"createBranch": "Create branch",
"branchName": "Branch name",
"createBranchHint": "The branch is created at this commit; you stay on the branch you are on.",
"branchCreated": "Created branch {{branch}} at {{short}}",
"createTagHere": "Create tag here…",
"createTagTitle": "New tag at {{short}}",
"createTag": "Create tag",
"tagName": "Tag name",
"tagMessage": "Message (optional — makes an annotated tag)",
"createTagHint": "The tag stays on this machine until you push it from the Branches panel.",
"tagCreated": "Created tag {{name}} at {{short}}"
}
+76
View File
@@ -0,0 +1,76 @@
{
"openPanel": "Manage branches & tags",
"openPanelHint": "Manage branches & tags: rename, new branch from any commit or tag, upstream, merge, delete, tags",
"title": "Branches and tags",
"branches": "Branches",
"tags": "Tags",
"close": "Close",
"searchBranches": "Search branches",
"searchTags": "Search tags",
"loading": "Loading…",
"unavailable": "This folder is not a git repository.",
"local": "Local",
"remote": "Remote · {{remote}}",
"noMatch": "No branch matches.",
"truncated": "Only the first 1000 of each are shown.",
"merging": "A merge, cherry-pick or revert is in progress: finish or abort it before changing branches or tags.",
"current": "current",
"protected": "protected",
"worktree": "worktree",
"gone": "upstream gone",
"ahead": "ahead",
"behind": "behind",
"inSync": "in sync",
"newFrom": "New branch from here",
"newBranchName": "New branch name",
"switchToIt": "Switch to it",
"switchDirty": "Commit or discard your changes to switch.",
"create": "Create",
"rename": "Rename",
"renameHint": "Rename this branch (its upstream setting moves with it)",
"renameTo": "New name",
"setUpstream": "Set upstream",
"upstreamHint": "Track a remote branch: ahead/behind, pull and push follow it",
"pickUpstream": "Pick a remote branch",
"noRemoteBranches": "No remote branches fetched yet",
"unsetUpstream": "Unset upstream",
"apply": "Apply",
"merge": "Merge",
"mergeInto": "Merge into {{current}}",
"mergeHint": "Merge this branch into {{current}} (a clean tree is needed)",
"mergeConfirm": "Merge {{branch}} into {{current}}? If it conflicts, you resolve it in the Changes panel or abort it.",
"delete": "Delete",
"deleteHint": "Delete this local branch (git branch -d)",
"deleteConfirm": "Delete the local branch {{branch}}? It is fully merged, so no commit is lost.",
"deleteUnmerged": "Delete the local branch {{branch}}? It is not merged into the current branch, so git will refuse unless its upstream has every commit.",
"why.current": "You are on this branch, so it cannot be deleted.",
"why.protected": "Protected branch: it cannot be renamed or deleted here.",
"why.worktree": "Checked out in another worktree: {{path}}",
"why.unmerged": "Not merged into the current branch: git refuses to delete it unless its upstream has every commit.",
"errLogin": "The remote wants a login. Use Fetch or Push on the git bar once to sign in (tick Remember), then try again.",
"errSubdir": "Branches and tags belong to the whole repository: do this from a workspace at the repository root.",
"tagNew": "New tag",
"tagName": "Tag name, e.g. v1.2.0",
"tagFrom": "On (branch, tag or commit sha) — empty for HEAD",
"tagMessage": "Message (optional — makes an annotated tag)",
"tagCreateLight": "Create tag",
"tagCreateAnnotated": "Create annotated tag",
"cancel": "Cancel",
"noTags": "No tags.",
"annotated": "annotated",
"tagPush": "Push to origin",
"tagPushHint": "Push only this tag (never --tags, never forced)",
"tagPushConfirm": "Push the tag {{tag}} to the remote?",
"tagDelete": "Delete tag",
"tagDeleteHint": "Delete this tag on this machine only",
"tagDeleteConfirm": "Delete the local tag {{tag}}? This cannot be undone here; a copy already pushed stays on the remote.",
"done.create": "Created branch {{branch}}",
"done.rename": "Renamed to {{branch}}",
"done.setUpstream": "Now tracking {{upstream}}",
"done.unsetUpstream": "Upstream removed",
"done.merge": "Merged {{branch}}",
"done.delete": "Deleted branch {{branch}}",
"done.tagCreate": "Created tag {{tag}}",
"done.tagPush": "Pushed tag {{tag}}",
"done.tagDelete": "Deleted tag {{tag}}"
}
+164
View File
@@ -0,0 +1,164 @@
{
"close": "Close",
"cancel": "Cancel",
"loading": "Loading…",
"pushed": "pushed",
"forceAck": "I understand: the branch will need a force-push afterwards",
"rewrites_one": "This rewrites {{count}} commit.",
"rewrites_other": "This rewrites {{count}} commits.",
"rewritesPushed_one": "This rewrites {{count}} commit; {{pushed}} already pushed, so a force-push will be needed.",
"rewritesPushed_other": "This rewrites {{count}} commits; {{pushed}} are already pushed, so a force-push will be needed.",
"doneTitle": "Done.",
"needsForcePushAfter": "Commits that were already pushed were rewritten: push with Force push (with lease) when you are sure.",
"undo": "Undo",
"undoConfirm": "Click again to undo",
"undoHint": "Puts the branch back at {{sha}} (local changes are kept).",
"undone": "Undone: the branch is back where it was",
"blocked.subdir": "This changes the whole branch, so it only runs from a workspace at the repository root.",
"blocked.operation": "A {{op}} is in progress: finish or abort it first.",
"blocked.dirty": "Commit or discard your uncommitted changes first.",
"menu.label": "History tools",
"menu.rebase": "Rebase onto…",
"menu.interactive": "Edit unpushed commits…",
"menu.reflog": "Reflog",
"menu.forcePush": "Force push (with lease)…",
"menu.remotes": "Remotes…",
"menu.resetHere": "Reset branch to here…",
"menu.interactiveHere": "Edit commits after this one…",
"merge.open": "Merge editor",
"merge.title": "Merge editor — {{path}}",
"merge.incoming": "Incoming: {{ref}}",
"merge.incomingRebase": "Incoming (commit being replayed): {{ref}}",
"merge.incomingPlain": "Incoming",
"merge.current": "Current: {{branch}}",
"merge.currentRebase": "Current (the branch you are rebasing onto)",
"merge.base": "Common base",
"merge.result": "Result",
"merge.sideGone": "Deleted on this side",
"merge.blockOf": "Conflict {{n}} of {{total}}",
"merge.acceptCurrent": "Accept current",
"merge.acceptIncoming": "Accept incoming",
"merge.bothCurrentFirst": "Both (current first)",
"merge.bothIncomingFirst": "Both (incoming first)",
"merge.editBlock": "Edit",
"merge.editDone": "Done",
"merge.undoChoice": "Undo",
"merge.left_one": "{{count}} of {{total}} conflicts left",
"merge.left_other": "{{count}} of {{total}} conflicts left",
"merge.allChosen": "Every conflict has a choice",
"merge.wholeMode": "Editing the whole result",
"merge.prev": "Previous conflict",
"merge.next": "Next conflict",
"merge.showBase": "Show base",
"merge.editWhole": "Edit whole result",
"merge.backToBlocks": "Back to conflicts",
"merge.save": "Save result",
"merge.saved": "{{path}} resolved",
"merge.markersLeft": "The result still has conflict markers (<<<<<<< ======= >>>>>>>): resolve every block first.",
"merge.stale": "The file changed on disk since it was opened. Close and open it again.",
"merge.binary": "This file is binary: take a whole side from the conflict view.",
"merge.tooBig": "This file is too large for the merge editor: take a whole side or edit it in a terminal.",
"merge.noWorking": "One side deleted this file: keep or delete it with Keep mine / Take theirs.",
"merge.unbalanced": "The conflict markers in this file are unbalanced: edit it by hand.",
"reflog.title": "Reflog",
"reflog.of": "Where the branch has been:",
"reflog.hint": "Every place HEAD or the branch pointed at, newest first. A commit you lost to a reset or a rebase is still here: Reset here brings it back.",
"reflog.empty": "Nothing recorded yet.",
"reflog.resetHere": "Reset here…",
"reflog.more": "Show more",
"rebase.title": "Rebase {{branch}} onto…",
"rebase.onto": "Branch to rebase onto",
"rebase.pick": "Pick a branch",
"rebase.explain": "Your branch's own commits are replayed one by one on top of the branch you pick. If a commit conflicts, the rebase stops and the Changes panel offers Continue, Skip and Abort.",
"rebase.nothing": "Nothing to replay: this branch has no commit that {{onto}} lacks.",
"rebase.summary_one": "{{count}} commit will be replayed onto {{onto}} ({{behind}} new commit(s) there).",
"rebase.summary_other": "{{count}} commits will be replayed onto {{onto}} ({{behind}} new commit(s) there).",
"rebase.run": "Rebase",
"rebase.done": "Rebased onto {{onto}}",
"rebase.stopped_one": "The rebase stopped: {{count}} file is in conflict. Resolve it, then Continue.",
"rebase.stopped_other": "The rebase stopped: {{count}} files are in conflict. Resolve them, then Continue.",
"rebaseBanner.skip": "Skip commit",
"rebaseBanner.skipHint": "Drop the commit the rebase stopped on and go on with the next one",
"rebaseBanner.finished": "Rebase finished",
"interactive.title": "Edit commits",
"interactive.explain": "Reorder with the arrows, and choose per commit: keep it, reword its message, squash or fixup it into the commit above, or drop it. Nothing changes until you press Rewrite.",
"interactive.merges": "This range contains a merge commit; rewrite it in a terminal instead.",
"interactive.nothing": "No unpushed commits to edit.",
"interactive.action": "What to do with {{short}}",
"interactive.actions.pick": "Keep",
"interactive.actions.reword": "Reword",
"interactive.actions.squash": "Squash into above",
"interactive.actions.fixup": "Fixup into above (drop message)",
"interactive.actions.drop": "Drop",
"interactive.up": "Move up",
"interactive.down": "Move down",
"interactive.message": "Commit message",
"interactive.squashHint": "Leave empty to join both messages",
"interactive.problem.first": "The first kept commit cannot be squashed: there is nothing above it.",
"interactive.problem.allDropped": "Every commit is dropped: use Reset branch to here instead.",
"interactive.problem.reword": "A reworded commit needs a message.",
"interactive.run": "Rewrite",
"interactive.runConfirm": "Click again to rewrite",
"interactive.done": "Commits rewritten",
"reset.title": "Reset {{branch}} to {{short}}",
"reset.summary": "{{dropped}} commit(s) leave {{branch}} and {{added}} join it.",
"reset.already": "The branch is already at this commit.",
"reset.unrelated": "This commit is not on this branch's line of history, so the branch cannot be reset to it here.",
"reset.mode": "What happens to your files",
"reset.modes.soft": "Soft",
"reset.modes.mixed": "Mixed",
"reset.modes.keep": "Keep",
"reset.modes.hard": "Hard",
"reset.modesHelp.soft": "Only the branch moves; the changes of the removed commits stay staged.",
"reset.modesHelp.mixed": "The branch moves; those changes stay in your files, unstaged.",
"reset.modesHelp.keep": "Files change to the commit, but git refuses if that would overwrite a change you have not committed.",
"reset.modesHelp.hard": "Files change to the commit exactly. Only on a clean tree, so nothing uncommitted is lost.",
"reset.hardDirty": "Hard reset runs only when there are no uncommitted changes. Use Keep, or commit/discard first.",
"reset.typeBranch": "Type the branch name ({{branch}}) to confirm",
"reset.pushed_one": "{{count}} of the commits leaving the branch is already pushed, so a force-push will be needed.",
"reset.pushed_other": "{{count}} of the commits leaving the branch are already pushed, so a force-push will be needed.",
"reset.run": "Reset ({{mode}})",
"reset.done": "Branch reset to {{short}}",
"forcePush.title": "Force push (with lease)",
"forcePush.where": "Pushes {{branch}} to {{remote}}/{{remoteBranch}}, replacing what is there.",
"forcePush.replaces": "It replaces {{sha}} on the remote (your branch is {{ahead}} ahead, {{behind}} behind it).",
"forcePush.newBranch": "The remote does not have this branch yet.",
"forcePush.dropsRemote_one": "{{count}} commit on the remote is not in your branch and will be gone from it.",
"forcePush.dropsRemote_other": "{{count}} commits on the remote are not in your branch and will be gone from it.",
"forcePush.protected": "This is a protected branch (main, master, develop…): force-pushing it is turned off on this server.",
"forcePush.lease": "With lease: the push is refused if someone pushed to the remote branch since your last fetch.",
"forcePush.run": "Force push",
"forcePush.runConfirm": "Click again to force push",
"forcePush.done": "Force-pushed to {{remote}}/{{branch}}",
"forcePush.stale": "The remote branch changed since your last fetch. Fetch, look at what is there, then try again.",
"remotes.title": "Remotes",
"remotes.none": "This repository has no remote.",
"remotes.default": "Default branch: {{branch}}",
"remotes.rename": "Rename",
"remotes.newName": "New name",
"remotes.remove": "Remove",
"remotes.removeConfirm": "Click again to remove",
"remotes.branches_one": "{{count}} branch",
"remotes.branches_other": "{{count}} branches",
"remotes.deleteBranch": "Delete on remote",
"remotes.deleteBranchConfirm": "Click again to delete",
"remotes.branchLocked": "The default branch and main/master/develop cannot be deleted here",
"remotes.add": "Add a remote",
"remotes.name": "Name",
"remotes.url": "URL",
"remotes.urlHint": "https:// (without a login in it), ssh:// or user@host:path.",
"remotes.addButton": "Add",
"remotes.added": "Remote {{name}} added",
"remotes.renamed": "Remote {{from}} renamed to {{to}}",
"remotes.removed": "Remote {{name}} removed",
"remotes.branchDeleted": "{{ref}} deleted on the remote"
}
+35 -2
View File
@@ -197,7 +197,7 @@
"gitBar.switchApply": "Switch branch",
"gitBar.switchCurrent": "current",
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
"gitBar.switchHint": "Switch this worktree to another local branch",
"gitBar.switchLabel": "Switch branch…",
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
@@ -712,5 +712,38 @@
"menu.newGroupWith": "New group…",
"tree.groupAddWorkspace": "New workspace in this group…",
"tree.groupFoldAll": "Fold all groups",
"tree.groupUnfoldAll": "Unfold all groups"
"tree.groupUnfoldAll": "Unfold all groups",
"gitBar.switchDirtyHint": "{{count}} uncommitted file(s): you will be asked whether to bring them along or shelve them",
"gitBar.choiceTitle": "Switch to {{branch}}",
"gitBar.choiceMessage": "You have {{count}} uncommitted file(s). Switch to {{branch}} and…",
"gitBar.choiceCarry": "Bring my changes",
"gitBar.choiceCarryHint": "Switch and keep the uncommitted changes in the working tree (git refuses if a file would be overwritten)",
"gitBar.choiceShelve": "Shelve and switch",
"gitBar.choiceShelveHint": "Set the changes aside on a shelf, then switch; restore them later from Shelves",
"gitBar.carryConflict": "Your changes cannot come along to {{branch}}: git would overwrite these files. Shelve them instead?",
"gitBar.branchCreateSwitch": "Create & switch",
"gitBar.branchCreateSwitchHint": "Create this branch and switch to it; uncommitted changes come along",
"gitBar.shelvedOn": "Your changes on {{branch}} are on a shelf",
"gitBar.restoreOfferTitle": "Restore shelved changes?",
"gitBar.restoreOffer": "{{branch}} has {{count}} shelf/shelves. Restore the newest ({{files}} file(s)) now?",
"shelves.title": "Shelves ({{count}})",
"shelves.files": "{{count}} file(s)",
"shelves.restore": "Restore",
"shelves.restoreHint": "Put these changes back (needs a clean working tree); the shelf is deleted once it applies cleanly",
"shelves.delete": "Delete",
"shelves.deleteConfirm": "Delete for good?",
"shelves.deleteHint": "Delete this shelf and the changes in it (two clicks)",
"shelves.deleted": "Shelf deleted",
"shelves.restored": "Shelved changes restored",
"shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.",
"shelves.restoreDirty": "Commit, discard or shelve the current changes before restoring a shelf",
"shelves.shelveTicked": "Shelve ({{count}})",
"shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree",
"shelves.shelveApply": "Shelve {{count}} file(s)",
"shelves.notePlaceholder": "Note for this shelf (optional)…",
"shelves.cancel": "Cancel",
"shelves.shelved": "{{count}} file(s) shelved",
"shelves.agentTitle": "An agent is working here",
"shelves.agentMessage": "An agent in this workspace is working right now. This moves files under it, and it may get confused or overwrite them. Continue anyway?",
"shelves.agentConfirm": "Continue"
}
+124
View File
@@ -0,0 +1,124 @@
{
"close": "Đóng",
"cancel": "Hủy",
"dismiss": "Ẩn",
"compare": "So sánh",
"commit": "Commit",
"blame": "Blame",
"binary": "nhị phân",
"running": "Đang chạy…",
"op.cherry-pick": "cherry-pick",
"op.revert": "revert",
"badge.local": "Nhánh {{name}}",
"badge.remote": "Nhánh remote {{name}}",
"badge.tag": "Tag {{name}}",
"badge.head": "HEAD (không ở nhánh nào)",
"badge.current": "{{name}} — nhánh bạn đang đứng",
"searchPlaceholder": "Tìm theo nội dung hoặc SHA — author:tên path:file",
"searchLabel": "Tìm trong lịch sử",
"clearSearch": "Xóa ô tìm",
"allBranches": "Mọi nhánh",
"noMatches": "Không có commit nào khớp",
"selectCommit": "Chọn {{short}}",
"selectedN_other": "Đã chọn {{count}} commit",
"clearSelection": "Bỏ chọn",
"mixedSelection": "Có commit đã nằm trên nhánh này, có commit thì chưa — hãy chỉ chọn một loại",
"mergeCommitRefused": "Không cherry-pick hay revert được merge commit ở đây — hãy làm trong terminal",
"pickN_other": "Cherry-pick {{count}} commit vào {{branch}}",
"revertN_other": "Revert {{count}} commit",
"pickedN_other": "Đã cherry-pick {{count}} commit vào {{branch}}.",
"revertedN_other": "Đã revert {{count}} commit trên {{branch}}.",
"pausedConflicts_other": "{{op}} dừng lại vì {{count}} file bị conflict — hãy giải quyết trong Changes rồi bấm Tiếp tục",
"pausedEmpty": "{{op}} dừng lại: thay đổi của một commit đã có sẵn trên nhánh này — hãy Bỏ qua hoặc Hủy",
"undo": "Hoàn tác",
"undoHint": "Gỡ các commit mới này khỏi nhánh (chỉ khi chúng chưa được push)",
"undoTitle": "Hoàn tác lần {{op}} này?",
"undoBody_other": "{{count}} commit mới sẽ bị gỡ khỏi {{branch}}. Thay đổi của chúng vẫn được giữ ở trạng thái staged nên không mất gì. Sẽ bị từ chối nếu đã push hoặc nhánh đã có commit mới hơn.",
"undone_other": "Đã hoàn tác {{count}} commit, thay đổi vẫn đang ở trạng thái staged",
"showDetails": "Xem chi tiết",
"browseFiles": "Xem các file ở commit này",
"compareWithCurrent": "So sánh với nhánh hiện tại",
"copyPatch": "Sao chép patch",
"noPatch": "Commit này không có patch",
"patchOmitted_other": "Đã bỏ {{count}} file chứa thông tin bí mật ra khỏi patch",
"createBranchHere": "Tạo nhánh tại đây…",
"detailsTitle": "Chi tiết commit",
"noDetails": "Không đọc được commit này",
"author": "Tác giả",
"committer": "Người commit",
"parents_other": "Commit cha",
"rootCommit": "không có (commit đầu tiên)",
"refs": "Ref",
"containedIn": "Có trong",
"viewAllChanges": "Xem mọi thay đổi",
"filesChanged_other": "{{count}} file thay đổi",
"planning": "Đang xếp thứ tự…",
"planUnavailable": "Thư mục này không phải git repo",
"pickTitle_other": "Cherry-pick {{count}} commit vào {{branch}}?",
"revertTitle_other": "Revert {{count}} commit?",
"pickIntro_other": "Lần lượt áp từng commit vào {{branch}} theo đúng thứ tự dưới đây, mỗi commit thành một commit mới:",
"revertIntro_other": "Thêm vào {{branch}} các commit mới để đảo ngược từng commit dưới đây, commit mới nhất trước:",
"treeClean": "Thư mục làm việc đang sạch.",
"blockDirty": "Thư mục làm việc còn thay đổi chưa commit. Hãy commit hoặc bỏ chúng trước, để lỡ có Hủy cũng không mất gì.",
"blockSubdir": "Workspace này là thư mục con của repo; cherry-pick hay revert thay đổi cả repo, nên hãy chạy từ workspace ở thư mục gốc của repo.",
"blockBusy": "Đang có một merge, cherry-pick, revert hoặc rebase dở dang. Hãy hoàn tất hoặc hủy nó trước.",
"pickResult": "File trong workspace thay đổi ngay. Các commit gốc giữ nguyên.",
"revertResult": "Lịch sử không bị viết lại: commit cũ vẫn còn, các commit mới sẽ đảo ngược chúng.",
"onConflict": "Nếu một commit bị conflict, {{op}} sẽ dừng ở đó: giải quyết file trong Changes rồi bấm Tiếp tục — hoặc Bỏ qua commit đó, hoặc Hủy để quay về đúng trạng thái hiện tại.",
"pausedAt": "{{op}} đang dừng ở {{short}} \"{{subject}}\" ({{step}}/{{total}}):",
"pausedBetween": "{{op}} đang dừng ({{step}}/{{total}}):",
"pausedHelpConflicts_other": "hãy giải quyết {{count}} file bị conflict rồi bấm Tiếp tục — hoặc Bỏ qua commit này, hoặc Hủy để quay về như trước.",
"pausedHelpReady": "đã giải quyết hết conflict — bấm Tiếp tục để commit và làm tiếp, hoặc Bỏ qua commit này, hoặc Hủy để quay về như trước.",
"pausedHelpResume": "bấm Tiếp tục để áp các commit còn lại, hoặc Hủy để quay về như trước.",
"stillToApply_other": "Còn lại ({{count}}): {{list}}",
"continue": "Tiếp tục",
"continueBlocked": "Hãy giải quyết hết các file bị conflict trước",
"skip": "Bỏ qua commit này",
"skipHint": "Bỏ commit này (kể cả phần đã giải quyết cho nó) và làm tiếp commit sau",
"skipConfirm": "Bấm lần nữa để bỏ qua",
"abort": "Hủy",
"abortHint": "Dừng lại và đưa nhánh về đúng như trước khi bắt đầu",
"abortConfirm": "Bấm lần nữa để hủy {{op}}",
"aborted": "Đã hủy {{op}} — nhánh đã về như trước",
"sequenceDone_other": "{{op}} xong",
"status.A": "Thêm mới",
"status.M": "Sửa",
"status.D": "Xóa",
"status.R": "Đổi tên",
"status.C": "Sao chép",
"renamedFrom": "đổi tên từ {{from}}",
"noBlame": "File này chưa có lịch sử để blame",
"blameTitle": "Blame {{path}}",
"workingTree": "bản đang làm",
"blameBefore": "Blame phiên bản trước thay đổi này",
"notCommitted": "chưa commit",
"browseTitle": "Các file ở {{short}}",
"pickAFile": "Chọn một file bên trái",
"binaryFile": "File nhị phân — không hiển thị",
"compareTitle": "So sánh",
"compareA": "Từ (nhánh, tag hoặc commit)",
"compareB": "Đến (nhánh, tag hoặc commit)",
"compareUnavailable": "Thư mục này không phải git repo",
"swap": "Đổi chỗ hai bên",
"modeThree": "Thay đổi kể từ lúc tách nhánh (A...B)",
"modeTwo": "Khác biệt giữa hai bên (A B)",
"tabFiles_other": "{{count}} file",
"tabOnly_other": "Chỉ có ở {{name}} ({{count}})",
"noDifference": "Không có file nào khác nhau",
"noCommitsSide": "Bên này không có commit riêng",
"backToFiles": "Quay lại danh sách file",
"secretFile": "File này chứa thông tin bí mật nên không hiển thị",
"noTextChange": "Không có thay đổi dạng văn bản (file nhị phân hoặc chỉ đổi quyền)",
"createBranchTitle": "Nhánh mới tại {{short}}",
"createBranch": "Tạo nhánh",
"branchName": "Tên nhánh",
"createBranchHint": "Nhánh được tạo tại commit này; bạn vẫn ở nhánh hiện tại.",
"branchCreated": "Đã tạo nhánh {{branch}} tại {{short}}",
"createTagHere": "Tạo tag tại đây…",
"createTagTitle": "Tag mới tại {{short}}",
"createTag": "Tạo tag",
"tagName": "Tên tag",
"tagMessage": "Lời nhắn (không bắt buộc — có lời nhắn sẽ thành annotated tag)",
"createTagHint": "Tag chỉ nằm trên máy này cho tới khi bạn push nó từ bảng Nhánh.",
"tagCreated": "Đã tạo tag {{name}} tại {{short}}"
}
+76
View File
@@ -0,0 +1,76 @@
{
"openPanel": "Nhánh và tag",
"openPanelHint": "Nhánh và tag: đổi tên, tạo nhánh từ commit hoặc tag bất kỳ, upstream, merge, xóa, tag",
"title": "Nhánh và tag",
"branches": "Nhánh",
"tags": "Tag",
"close": "Đóng",
"searchBranches": "Tìm nhánh",
"searchTags": "Tìm tag",
"loading": "Đang tải…",
"unavailable": "Thư mục này không phải là kho git.",
"local": "Trên máy",
"remote": "Remote · {{remote}}",
"noMatch": "Không có nhánh nào khớp.",
"truncated": "Chỉ hiện 1000 mục đầu của mỗi loại.",
"merging": "Đang có merge, cherry-pick hoặc revert dở: hãy hoàn tất hoặc hủy nó trước khi đổi nhánh hay tag.",
"current": "đang dùng",
"protected": "được bảo vệ",
"worktree": "worktree",
"gone": "upstream đã mất",
"ahead": "đi trước",
"behind": "đi sau",
"inSync": "đã đồng bộ",
"newFrom": "Tạo nhánh từ đây",
"newBranchName": "Tên nhánh mới",
"switchToIt": "Chuyển sang nhánh đó",
"switchDirty": "Hãy commit hoặc bỏ các thay đổi rồi mới chuyển nhánh được.",
"create": "Tạo",
"rename": "Đổi tên",
"renameHint": "Đổi tên nhánh này (thiết lập upstream đi theo)",
"renameTo": "Tên mới",
"setUpstream": "Đặt upstream",
"upstreamHint": "Theo dõi một nhánh remote: số commit đi trước/đi sau, pull và push sẽ dựa vào nó",
"pickUpstream": "Chọn nhánh remote",
"noRemoteBranches": "Chưa fetch nhánh remote nào",
"unsetUpstream": "Bỏ upstream",
"apply": "Áp dụng",
"merge": "Merge",
"mergeInto": "Merge vào {{current}}",
"mergeHint": "Merge nhánh này vào {{current}} (cần cây làm việc sạch)",
"mergeConfirm": "Merge {{branch}} vào {{current}}? Nếu có xung đột, bạn xử lý trong bảng Thay đổi hoặc hủy merge.",
"delete": "Xóa",
"deleteHint": "Xóa nhánh trên máy này (git branch -d)",
"deleteConfirm": "Xóa nhánh {{branch}} trên máy? Nhánh đã merge hết nên không mất commit nào.",
"deleteUnmerged": "Xóa nhánh {{branch}} trên máy? Nhánh chưa merge vào nhánh hiện tại, nên git sẽ từ chối trừ khi upstream của nó đã có đủ mọi commit.",
"why.current": "Bạn đang ở nhánh này nên không xóa được.",
"why.protected": "Nhánh được bảo vệ: không đổi tên hay xóa ở đây được.",
"why.worktree": "Đang được mở ở worktree khác: {{path}}",
"why.unmerged": "Chưa merge vào nhánh hiện tại: git sẽ không xóa trừ khi upstream của nó đã có đủ mọi commit.",
"errLogin": "Remote cần đăng nhập. Hãy bấm Fetch hoặc Push trên thanh git một lần để đăng nhập (tick Ghi nhớ), rồi thử lại.",
"errSubdir": "Nhánh và tag thuộc về cả kho: hãy làm việc này từ một workspace ở thư mục gốc của kho.",
"tagNew": "Tạo tag",
"tagName": "Tên tag, ví dụ v1.2.0",
"tagFrom": "Đặt trên (nhánh, tag hoặc mã commit) — để trống là HEAD",
"tagMessage": "Nội dung (không bắt buộc — có nội dung thì là tag có chú thích)",
"tagCreateLight": "Tạo tag",
"tagCreateAnnotated": "Tạo tag có chú thích",
"cancel": "Hủy",
"noTags": "Chưa có tag nào.",
"annotated": "có chú thích",
"tagPush": "Đẩy lên origin",
"tagPushHint": "Chỉ đẩy đúng tag này (không dùng --tags, không ghi đè)",
"tagPushConfirm": "Đẩy tag {{tag}} lên remote?",
"tagDelete": "Xóa tag",
"tagDeleteHint": "Chỉ xóa tag này trên máy",
"tagDeleteConfirm": "Xóa tag {{tag}} trên máy? Không hoàn tác được ở đây; bản đã đẩy lên remote vẫn còn.",
"done.create": "Đã tạo nhánh {{branch}}",
"done.rename": "Đã đổi tên thành {{branch}}",
"done.setUpstream": "Giờ đang theo dõi {{upstream}}",
"done.unsetUpstream": "Đã bỏ upstream",
"done.merge": "Đã merge {{branch}}",
"done.delete": "Đã xóa nhánh {{branch}}",
"done.tagCreate": "Đã tạo tag {{tag}}",
"done.tagPush": "Đã đẩy tag {{tag}}",
"done.tagDelete": "Đã xóa tag {{tag}}"
}
+156
View File
@@ -0,0 +1,156 @@
{
"close": "Đóng",
"cancel": "Huỷ",
"loading": "Đang tải…",
"pushed": "đã push",
"forceAck": "Tôi hiểu: sau đó phải force-push nhánh này",
"rewrites_other": "Thao tác này viết lại {{count}} commit.",
"rewritesPushed_other": "Thao tác này viết lại {{count}} commit, trong đó {{pushed}} commit đã push nên sẽ phải force-push.",
"doneTitle": "Xong.",
"needsForcePushAfter": "Có commit đã push bị viết lại: khi chắc chắn, hãy push bằng Force push (with lease).",
"undo": "Hoàn tác",
"undoConfirm": "Bấm lần nữa để hoàn tác",
"undoHint": "Đưa nhánh về lại {{sha}} (giữ nguyên thay đổi chưa commit).",
"undone": "Đã hoàn tác: nhánh đã về chỗ cũ",
"blocked.subdir": "Thao tác này đổi cả nhánh nên chỉ chạy được từ workspace ở thư mục gốc của repo.",
"blocked.operation": "Đang có {{op}} dở dang: hãy hoàn tất hoặc huỷ trước.",
"blocked.dirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước.",
"menu.label": "Công cụ lịch sử",
"menu.rebase": "Rebase lên nhánh…",
"menu.interactive": "Sửa các commit chưa push…",
"menu.reflog": "Reflog",
"menu.forcePush": "Force push (with lease)…",
"menu.remotes": "Remote…",
"menu.resetHere": "Reset nhánh về commit này…",
"menu.interactiveHere": "Sửa các commit sau commit này…",
"merge.open": "Trình gộp 3 bên",
"merge.title": "Gộp conflict — {{path}}",
"merge.incoming": "Bên đến: {{ref}}",
"merge.incomingRebase": "Bên đến (commit đang được áp lại): {{ref}}",
"merge.incomingPlain": "Bên đến",
"merge.current": "Hiện tại: {{branch}}",
"merge.currentRebase": "Hiện tại (nhánh bạn đang rebase lên)",
"merge.base": "Bản gốc chung",
"merge.result": "Kết quả",
"merge.sideGone": "Bên này đã xoá file",
"merge.blockOf": "Conflict {{n}}/{{total}}",
"merge.acceptCurrent": "Lấy bản hiện tại",
"merge.acceptIncoming": "Lấy bản đến",
"merge.bothCurrentFirst": "Lấy cả hai (hiện tại trước)",
"merge.bothIncomingFirst": "Lấy cả hai (bản đến trước)",
"merge.editBlock": "Sửa",
"merge.editDone": "Xong",
"merge.undoChoice": "Bỏ chọn",
"merge.left_other": "Còn {{count}}/{{total}} conflict",
"merge.allChosen": "Đã chọn xong mọi conflict",
"merge.wholeMode": "Đang sửa toàn bộ kết quả",
"merge.prev": "Conflict trước",
"merge.next": "Conflict sau",
"merge.showBase": "Xem bản gốc",
"merge.editWhole": "Sửa cả file kết quả",
"merge.backToBlocks": "Quay lại từng conflict",
"merge.save": "Lưu kết quả",
"merge.saved": "Đã giải quyết {{path}}",
"merge.markersLeft": "Kết quả vẫn còn dấu conflict (<<<<<<< ======= >>>>>>>): hãy xử lý hết các khối trước.",
"merge.stale": "File đã thay đổi trên đĩa từ lúc mở. Hãy đóng và mở lại.",
"merge.binary": "Đây là file nhị phân: hãy chọn nguyên một bên ở màn hình conflict.",
"merge.tooBig": "File quá lớn cho trình gộp: hãy chọn nguyên một bên hoặc sửa trong terminal.",
"merge.noWorking": "Một bên đã xoá file này: hãy giữ hoặc xoá bằng Giữ của mình / Lấy bản kia.",
"merge.unbalanced": "Dấu conflict trong file bị lệch: hãy sửa tay.",
"reflog.title": "Reflog",
"reflog.of": "Các vị trí nhánh từng trỏ tới:",
"reflog.hint": "Mọi chỗ HEAD hoặc nhánh từng trỏ tới, mới nhất ở trên. Commit bị mất do reset hay rebase vẫn còn ở đây: bấm Reset về đây để lấy lại.",
"reflog.empty": "Chưa có gì được ghi lại.",
"reflog.resetHere": "Reset về đây…",
"reflog.more": "Xem thêm",
"rebase.title": "Rebase {{branch}} lên…",
"rebase.onto": "Nhánh để rebase lên",
"rebase.pick": "Chọn nhánh",
"rebase.explain": "Các commit riêng của nhánh bạn sẽ được áp lại lần lượt lên trên nhánh bạn chọn. Nếu một commit bị conflict, rebase dừng lại và bảng Thay đổi sẽ có Tiếp tục, Bỏ qua và Huỷ.",
"rebase.nothing": "Không có gì để áp lại: nhánh này không có commit nào mà {{onto}} chưa có.",
"rebase.summary_other": "{{count}} commit sẽ được áp lại lên {{onto}} (bên đó có {{behind}} commit mới).",
"rebase.run": "Rebase",
"rebase.done": "Đã rebase lên {{onto}}",
"rebase.stopped_other": "Rebase đã dừng: {{count}} file bị conflict. Xử lý xong rồi bấm Tiếp tục.",
"rebaseBanner.skip": "Bỏ qua commit",
"rebaseBanner.skipHint": "Bỏ commit mà rebase đang dừng và làm tiếp commit sau",
"rebaseBanner.finished": "Đã rebase xong",
"interactive.title": "Sửa commit",
"interactive.explain": "Đổi thứ tự bằng các mũi tên và chọn cho từng commit: giữ nguyên, sửa nội dung message, gộp (squash hoặc fixup) vào commit phía trên, hoặc bỏ đi. Chưa có gì thay đổi cho tới khi bạn bấm Viết lại.",
"interactive.merges": "Khoảng này có merge commit, hãy viết lại trong terminal.",
"interactive.nothing": "Không có commit chưa push nào để sửa.",
"interactive.action": "Làm gì với {{short}}",
"interactive.actions.pick": "Giữ",
"interactive.actions.reword": "Sửa message",
"interactive.actions.squash": "Gộp vào commit trên",
"interactive.actions.fixup": "Gộp vào commit trên (bỏ message)",
"interactive.actions.drop": "Bỏ",
"interactive.up": "Lên trên",
"interactive.down": "Xuống dưới",
"interactive.message": "Message của commit",
"interactive.squashHint": "Để trống thì ghép cả hai message",
"interactive.problem.first": "Commit đầu tiên được giữ không gộp được: phía trên nó không có gì.",
"interactive.problem.allDropped": "Mọi commit đều bị bỏ: hãy dùng Reset nhánh về commit này.",
"interactive.problem.reword": "Commit sửa message thì cần có message.",
"interactive.run": "Viết lại",
"interactive.runConfirm": "Bấm lần nữa để viết lại",
"interactive.done": "Đã viết lại các commit",
"reset.title": "Reset {{branch}} về {{short}}",
"reset.summary": "{{dropped}} commit rời khỏi {{branch}} và {{added}} commit được thêm vào.",
"reset.already": "Nhánh đang ở đúng commit này rồi.",
"reset.unrelated": "Commit này không nằm trên lịch sử của nhánh nên không reset về được ở đây.",
"reset.mode": "File của bạn sẽ ra sao",
"reset.modes.soft": "Soft",
"reset.modes.mixed": "Mixed",
"reset.modes.keep": "Keep",
"reset.modes.hard": "Hard",
"reset.modesHelp.soft": "Chỉ nhánh di chuyển; thay đổi của các commit bị bỏ vẫn nằm trong vùng stage.",
"reset.modesHelp.mixed": "Nhánh di chuyển; các thay đổi đó vẫn còn trong file, chưa stage.",
"reset.modesHelp.keep": "File đổi theo commit, nhưng git sẽ từ chối nếu việc đó ghi đè lên thay đổi chưa commit.",
"reset.modesHelp.hard": "File đổi đúng y như commit. Chỉ chạy khi không có thay đổi chưa commit, nên không mất gì.",
"reset.hardDirty": "Hard reset chỉ chạy khi không có thay đổi chưa commit. Hãy dùng Keep, hoặc commit/bỏ thay đổi trước.",
"reset.typeBranch": "Gõ tên nhánh ({{branch}}) để xác nhận",
"reset.pushed_other": "{{count}} commit rời khỏi nhánh đã được push, nên sẽ phải force-push.",
"reset.run": "Reset ({{mode}})",
"reset.done": "Đã reset nhánh về {{short}}",
"forcePush.title": "Force push (with lease)",
"forcePush.where": "Push {{branch}} lên {{remote}}/{{remoteBranch}}, thay thế những gì đang có ở đó.",
"forcePush.replaces": "Sẽ thay {{sha}} trên remote (nhánh của bạn đi trước {{ahead}}, đi sau {{behind}} commit).",
"forcePush.newBranch": "Remote chưa có nhánh này.",
"forcePush.dropsRemote_other": "{{count}} commit trên remote không có trong nhánh của bạn và sẽ biến mất khỏi đó.",
"forcePush.protected": "Đây là nhánh được bảo vệ (main, master, develop…): server này tắt force-push cho nhánh đó.",
"forcePush.lease": "With lease: push bị từ chối nếu có ai đã push lên nhánh remote kể từ lần fetch gần nhất của bạn.",
"forcePush.run": "Force push",
"forcePush.runConfirm": "Bấm lần nữa để force push",
"forcePush.done": "Đã force push lên {{remote}}/{{branch}}",
"forcePush.stale": "Nhánh remote đã thay đổi từ lần fetch gần nhất. Hãy fetch, xem trên đó có gì rồi thử lại.",
"remotes.title": "Remote",
"remotes.none": "Repo này chưa có remote.",
"remotes.default": "Nhánh mặc định: {{branch}}",
"remotes.rename": "Đổi tên",
"remotes.newName": "Tên mới",
"remotes.remove": "Gỡ",
"remotes.removeConfirm": "Bấm lần nữa để gỡ",
"remotes.branches_other": "{{count}} nhánh",
"remotes.deleteBranch": "Xoá trên remote",
"remotes.deleteBranchConfirm": "Bấm lần nữa để xoá",
"remotes.branchLocked": "Không xoá được nhánh mặc định và main/master/develop ở đây",
"remotes.add": "Thêm remote",
"remotes.name": "Tên",
"remotes.url": "URL",
"remotes.urlHint": "https:// (không kèm thông tin đăng nhập), ssh:// hoặc user@host:path.",
"remotes.addButton": "Thêm",
"remotes.added": "Đã thêm remote {{name}}",
"remotes.renamed": "Đã đổi tên remote {{from}} thành {{to}}",
"remotes.removed": "Đã gỡ remote {{name}}",
"remotes.branchDeleted": "Đã xoá {{ref}} trên remote"
}
+35 -2
View File
@@ -197,7 +197,7 @@
"gitBar.switchApply": "Chuyển nhánh",
"gitBar.switchCurrent": "hiện tại",
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
"gitBar.switchLabel": "Đổi nhánh…",
"gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.",
@@ -712,5 +712,38 @@
"menu.newGroupWith": "Nhóm mới…",
"tree.groupAddWorkspace": "Workspace mới trong nhóm này…",
"tree.groupFoldAll": "Thu gọn mọi nhóm",
"tree.groupUnfoldAll": "Mở rộng mọi nhóm"
"tree.groupUnfoldAll": "Mở rộng mọi nhóm",
"gitBar.switchDirtyHint": "{{count}} file chưa commit: bạn sẽ được hỏi mang theo hay cất chúng lại",
"gitBar.choiceTitle": "Chuyển sang {{branch}}",
"gitBar.choiceMessage": "Bạn có {{count}} file chưa commit. Chuyển sang {{branch}} và…",
"gitBar.choiceCarry": "Mang thay đổi theo",
"gitBar.choiceCarryHint": "Chuyển nhánh và giữ nguyên thay đổi chưa commit trong thư mục (git từ chối nếu một file sẽ bị ghi đè)",
"gitBar.choiceShelve": "Cất rồi chuyển",
"gitBar.choiceShelveHint": "Cất thay đổi vào một shelf rồi chuyển nhánh; khôi phục lại sau ở mục Shelves",
"gitBar.carryConflict": "Không thể mang thay đổi sang {{branch}}: git sẽ ghi đè các file này. Cất chúng lại thay vì vậy?",
"gitBar.branchCreateSwitch": "Tạo & chuyển",
"gitBar.branchCreateSwitchHint": "Tạo nhánh này và chuyển sang; thay đổi chưa commit được mang theo",
"gitBar.shelvedOn": "Thay đổi trên {{branch}} đã được cất vào shelf",
"gitBar.restoreOfferTitle": "Khôi phục thay đổi đã cất?",
"gitBar.restoreOffer": "{{branch}} có {{count}} shelf. Khôi phục shelf mới nhất ({{files}} file) ngay?",
"shelves.title": "Shelves ({{count}})",
"shelves.files": "{{count}} file",
"shelves.restore": "Khôi phục",
"shelves.restoreHint": "Đưa các thay đổi này trở lại (cần thư mục sạch); shelf được xoá khi áp dụng không xung đột",
"shelves.delete": "Xoá",
"shelves.deleteConfirm": "Xoá hẳn?",
"shelves.deleteHint": "Xoá shelf này cùng các thay đổi trong nó (bấm hai lần)",
"shelves.deleted": "Đã xoá shelf",
"shelves.restored": "Đã khôi phục thay đổi đã cất",
"shelves.restoredConflicts": "Đã khôi phục với {{count}} file xung đột: hãy giải quyết trong danh sách. Shelf được giữ đến khi bạn xoá.",
"shelves.restoreDirty": "Hãy commit, bỏ hoặc cất thay đổi hiện tại trước khi khôi phục một shelf",
"shelves.shelveTicked": "Cất ({{count}})",
"shelves.shelveHint": "Cất các file đã tick vào một shelf và dọn chúng khỏi thư mục làm việc",
"shelves.shelveApply": "Cất {{count}} file",
"shelves.notePlaceholder": "Ghi chú cho shelf này (tuỳ chọn)…",
"shelves.cancel": "Huỷ",
"shelves.shelved": "Đã cất {{count}} file",
"shelves.agentTitle": "Một agent đang làm việc ở đây",
"shelves.agentMessage": "Một agent trong workspace này đang làm việc. Thao tác này di chuyển file ngay dưới nó, agent có thể bị nhầm hoặc ghi đè lên. Vẫn tiếp tục?",
"shelves.agentConfirm": "Tiếp tục"
}
@@ -0,0 +1,90 @@
/**
* @file Pins the cause of "after Switch branch the Changes panel still shows
* the old branch": `request()` shares one in-flight GET between callers, and
* the panel's re-read after a switch joined a poll that had STARTED before
* the switch finished — so it got the old branch's answer. A write now
* closes every in-flight GET to new joiners (before it is sent and once it
* settles), and `gitAction` announces itself (`agenthub:git-changed`) so the
* views that never hear the bar's callback read again too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { api } from "../api";
import { GIT_CHANGED_EVENT } from "../gitChanged";
afterEach(() => {
vi.unstubAllGlobals();
});
function json(body: unknown) {
return {
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: async () => body,
};
}
describe("a git write and the reads around it", () => {
it("a read asked after a switch never joins a read started before it", async () => {
let releaseOld: (v: unknown) => void = () => {};
const spy = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === "POST") return Promise.resolve(json({ ok: true, action: "switch" }));
// The first GET (a poll) hangs until released, answering the OLD branch.
if (spy.mock.calls.filter((c) => !c[1]?.method).length === 1) {
return new Promise((r) => {
releaseOld = () => r(json({ branch: "old" }));
});
}
return Promise.resolve(json({ branch: "new" }));
});
vi.stubGlobal("fetch", spy);
const poll = api.lanes.git(3);
await api.lanes.gitAction(3, "switch", { branch: "new" });
const after = api.lanes.git(3);
releaseOld(undefined);
expect(await poll).toEqual({ branch: "old" });
expect(await after).toEqual({ branch: "new" });
expect(spy.mock.calls.filter((c) => !c[1]?.method)).toHaveLength(2);
});
it("still shares a read between callers when no write came between them", async () => {
const spy = vi.fn(() => Promise.resolve(json({ branch: "x" })));
vi.stubGlobal("fetch", spy);
await Promise.all([api.lanes.git(4), api.lanes.git(4)]);
expect(spy).toHaveBeenCalledTimes(1);
});
it("announces every git write for its workspace, a failed one included", async () => {
const seen: unknown[] = [];
const on = (e: Event) => seen.push((e as CustomEvent).detail);
window.addEventListener(GIT_CHANGED_EVENT, on);
try {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(json({ ok: true })))
);
await api.lanes.gitAction({ lane: 5, session: "s" }, "shelve", {});
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: false,
status: 409,
headers: { get: () => "application/json" },
json: async () => ({ error: { code: "ESHELVED", message: "x" } }),
})
)
);
await expect(api.lanes.gitAction(5, "switch", { branch: "b" })).rejects.toThrow();
} finally {
window.removeEventListener(GIT_CHANGED_EVENT, on);
}
expect(seen).toEqual([
{ laneId: 5, action: "shelve" },
{ laneId: 5, action: "switch" },
]);
});
});
@@ -432,6 +432,14 @@ describe("readRunBusy", () => {
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
// Claude's spinner with the footer's hint gone (a narrow pane, a tip).
rows = ["✶ Skedaddling… (41s · ↓ 2.8k tokens · thought for 2s)", " ⏵⏵ auto mode on"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
rows = ["✻ Thinking… (1m 3s)"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
// The line a finished turn leaves is not.
rows = ["✻ Cooked for 1m 2s", "❯ "];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
// Claude's permission footer is not a turn at work.
rows = ["Esc to cancel · Tab to amend"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
+272 -14
View File
@@ -434,10 +434,24 @@ import type {
ReportInput,
ReportRun,
} from "./types";
import type {
BlameAt,
CommitDetails,
CommitPatch,
CompareFile,
CompareResult,
FileHistoryPage,
PickAction,
PickPlan,
PickResult,
PickSequence,
TreeAt,
} from "./gitHistory";
import { activeSourcesParam } from "./dataScope";
import i18n from "i18next";
import { applyRange, type DateRange } from "./dateRange";
import { emitGitChanged } from "./gitChanged";
// Root path all endpoint paths are appended to. Kept relative (no host) so the
// same client bundle works behind the Vite dev proxy and in same-origin prod.
@@ -995,16 +1009,23 @@ export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | n
/** GETs in flight, by path: concurrent callers of the same read (several
* components mounting, a WS event fanning out to many listeners) share one
* fetch and one parsed result. Deleted as soon as it settles, so this is
* dedupe, never a cache. Callers must treat the result as read-only. */
* dedupe, never a cache. Callers must treat the result as read-only.
* A WRITE closes every one of them to new joiners, before it is sent and
* again once it settles: a read asked after a write must see the write, and
* joining a poll that started before it handed back the old state (after a
* branch switch, the Changes panel kept showing the branch just left). */
const inflight = new Map<string, Promise<unknown>>();
function request<T>(path: string, options?: RequestInit): Promise<T> {
export function request<T>(path: string, options?: RequestInit): Promise<T> {
// Only a plain GET: a body, headers or an AbortSignal belongs to one caller.
const plainGet =
!options ||
(Object.keys(options).every((k) => k === "method") &&
(options.method ?? "GET").toUpperCase() === "GET");
if (!plainGet) return send<T>(path, options);
if (!plainGet) {
inflight.clear();
return send<T>(path, options).finally(() => inflight.clear());
}
const hit = inflight.get(path);
if (hit) return hit as Promise<T>;
const p = send<T>(path, options).finally(() => inflight.delete(path));
@@ -1048,6 +1069,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
auth: body?.error?.auth as GitAuthNeeded | undefined,
// ESESSIONLIVE from a workspace start: the pane already holding it.
runId: body?.error?.run_id as string | undefined,
// ECARRYCONFLICT from a lane git switch: the files it would overwrite.
files: body?.error?.files as string[] | undefined,
// EEXISTS from the File Explorer: the names already taken.
names: body?.error?.names as string[] | undefined,
// ETOOBIG from a prompt drop: the server's own limit.
@@ -1100,7 +1123,21 @@ export type GitTarget = number | { lane: number; session?: string; source?: numb
export type GitSource = number | string;
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
/**
* A git write, then `agenthub:git-changed` for its workspace — success or
* failure, since a refused shelve or a conflicted restore may still have
* moved files — so every view of that workspace reads again (gitChanged.ts).
*/
function requestAndAnnounce<T>(
path: string,
options: RequestInit,
laneId: number,
action: string
): Promise<T> {
return request<T>(path, options).finally(() => emitGitChanged(laneId, action));
}
export function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
const params = new URLSearchParams(q);
const lane = typeof target === "number" ? target : target.lane;
if (typeof target !== "number" && target.session) params.set("session", target.session);
@@ -1217,6 +1254,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,
@@ -1228,6 +1329,21 @@ export function gitTarget(
return session ? { lane: laneId, session } : laneId;
}
/** A shelf: changes set aside by AgentHub (a git stash entry it made). */
export interface GitShelf {
/** `stash@{n}` right now — informational; writes address `sha`. */
ref: string;
/** The stash commit; what unshelve and shelf-drop take. */
sha: string;
/** The branch it was shelved on. */
branch: string;
note: string;
/** ISO time it was shelved. */
createdAt: string | null;
/** Its files; `??` = an untracked file. */
files: { path: string; status: string }[];
}
/** A file the dashboard machine's file manager holds, by its own path. */
export interface FileManagerFile {
path: string;
@@ -4294,13 +4410,108 @@ export const api = {
/** GET /api/lanes/:id/git/blame — Annotate: each line's last commit. */
gitBlame: (id: GitTarget, path: string) =>
request<LaneBlame>(gitUrl(id, "/blame", new URLSearchParams({ path }))),
gitLog: (id: GitTarget, opts: { limit?: number; file?: string; ref?: string } = {}) => {
gitLog: (
id: GitTarget,
opts: {
limit?: number;
file?: string;
ref?: string;
/** The page's start (the graph's paging). */
skip?: number;
/** Every branch, remote branch and tag. */
all?: boolean;
/** Search: message text (or a SHA prefix), author, path. */
q?: string;
author?: string;
path?: string;
} = {}
) => {
const q = new URLSearchParams();
if (opts.limit) q.set("limit", String(opts.limit));
if (opts.file) q.set("file", opts.file);
// Another branch's history (cherry-pick's source); absent = HEAD's.
if (opts.ref) q.set("ref", opts.ref);
return request<{ available: boolean; commits: LaneCommit[] }>(gitUrl(id, "/log", q));
if (opts.skip) q.set("skip", String(opts.skip));
if (opts.all) q.set("all", "1");
if (opts.q) q.set("q", opts.q);
if (opts.author) q.set("author", opts.author);
if (opts.path) q.set("path", opts.path);
return request<{
available: boolean;
commits: LaneCommit[];
hasMore?: boolean;
filtered?: boolean;
}>(gitUrl(id, "/log", q));
},
/** The history reads and the multi-commit pick (server/lib/git-history.js). */
gitHistory: {
commit: (id: GitTarget, sha: string) =>
request<CommitDetails | { available: false }>(
gitUrl(id, "/commit", new URLSearchParams({ sha }))
),
patch: (id: GitTarget, sha: string) =>
request<CommitPatch | { available: false }>(
gitUrl(id, "/patch", new URLSearchParams({ sha }))
),
fileHistory: (id: GitTarget, path: string, opts: { skip?: number; limit?: number } = {}) => {
const q = new URLSearchParams({ path });
if (opts.skip) q.set("skip", String(opts.skip));
if (opts.limit) q.set("limit", String(opts.limit));
return request<FileHistoryPage | { available: false }>(gitUrl(id, "/file-history", q));
},
blameAt: (id: GitTarget, path: string, rev = "work") =>
request<BlameAt | { available: false }>(
gitUrl(id, "/blame-at", new URLSearchParams({ path, rev }))
),
treeAt: (id: GitTarget, rev: string, path = "") =>
request<TreeAt | { available: false }>(
gitUrl(id, "/tree-at", new URLSearchParams({ rev, path }))
),
compare: (id: GitTarget, a: string, b: string, mode: "three" | "two" = "three") =>
request<CompareResult | { available: false }>(
gitUrl(id, "/compare", new URLSearchParams({ a, b, mode }))
),
compareFile: (
id: GitTarget,
a: string,
b: string,
mode: "three" | "two",
path: string,
from?: string
) => {
const q = new URLSearchParams({ a, b, mode, path });
if (from) q.set("from", from);
return request<CompareFile | { available: false }>(gitUrl(id, "/compare", q));
},
pickPlan: (id: GitTarget, op: "cherry-pick" | "revert", shas: string[]) =>
request<PickPlan | { available: false }>(
gitUrl(id, "/pick-plan", new URLSearchParams({ op, shas: shas.join(",") }))
),
sequence: (id: GitTarget) =>
request<PickSequence | { available: false }>(gitUrl(id, "/sequence")),
/** POST cherry-pick / revert with `shas` (git-pick.js). */
// Both announce `agenthub:git-changed`, like every other git write.
pick: (id: GitTarget, op: "cherry-pick" | "revert", shas: string[]) =>
requestAndAnnounce<PickResult>(
gitUrl(id, `/${op}`),
{ method: "POST", body: JSON.stringify({ shas }) },
typeof id === "number" ? id : id.lane,
op
),
/** POST pick-continue / pick-skip / pick-abort / undo-pick. */
act: (
id: GitTarget,
action: PickAction,
body: { confirm?: boolean; from?: string; to?: string } = {}
) =>
requestAndAnnounce<
PickResult & { undone?: { sha: string; short: string; subject: string }[] }
>(
gitUrl(id, `/${action}`),
{ method: "POST", body: JSON.stringify(body) },
typeof id === "number" ? id : id.lane,
action
),
},
/**
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
@@ -4397,6 +4608,25 @@ 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.
*/
gitShelves: (id: GitTarget) =>
request<{ available: boolean; shelves: GitShelf[] }>(gitUrl(id, "/shelves")),
/**
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
* written by the user's `claude` CLI (no tools, nothing persisted).
@@ -4411,7 +4641,7 @@ export const api = {
* @param id The lane id.
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
* `unstage`, `discard`, `commit`, `switch`, `branch-create`,
* `branch-delete` or `publish`.
* `branch-delete`, `publish`, `shelve`, `unshelve` or `shelf-drop`.
* @param body `{message, only}` for commit; `{branch, create}` for switch;
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
* `{paths, confirm: true}` for discard — which is irreversible, so the
@@ -4443,8 +4673,17 @@ export const api = {
| "revert"
| "undo-commit"
| "amend"
| "ignore",
| "ignore"
| "shelve"
| "unshelve"
| "shelf-drop",
body: {
/** switch: what happens to uncommitted changes (absent = refuse a dirty tree). */
changes?: "carry" | "shelve";
/** branch-create: also switch to the new branch, carrying the changes. */
switch?: boolean;
/** shelve, switch with changes: "shelve": a one-line note on the shelf. */
note?: string;
/** cherry-pick / revert: the commit. */
sha?: string;
url?: string;
@@ -4467,9 +4706,25 @@ export const api = {
credentials?: GitCredentialInput & { remember: boolean };
} = {}
) =>
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
requestAndAnnounce<{
action: string;
ok: true;
output: string;
branch?: string;
conflicts?: string[];
/** switch: the TARGET branch's shelves, to offer back. */
shelves?: GitShelf[];
/** switch with changes: "shelve": the shelf it made. */
shelved?: GitShelf;
/** shelve: the shelf it made. */
shelf?: GitShelf;
/** unshelve: true when conflicts kept the shelf. */
kept?: boolean;
}>(
gitUrl(id, `/${action}`),
{ method: "POST", body: JSON.stringify(body) }
{ method: "POST", body: JSON.stringify(body) },
typeof id === "number" ? id : id.lane,
action
),
/**
* GET /api/lanes/:id/runtime — the lane's own application stack: slot,
@@ -4550,10 +4805,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`, {
+3 -8
View File
@@ -33,10 +33,7 @@ export const isActiveJob = (j: Pick<CloneJob, "state">) =>
type OnDone = (job: CloneJob, lane?: Lane) => void;
type Creds = { username: string; password: string; remember?: boolean };
type WorkspaceBody = Omit<Parameters<typeof api.lanes.clone>[0], "background" | "progressToken">;
type SourceBody = Omit<
Parameters<typeof api.lanes.cloneFolder>[1],
"background" | "progressToken"
>;
type SourceBody = Omit<Parameters<typeof api.lanes.cloneFolder>[1], "background" | "progressToken">;
let jobs: CloneJob[] = [];
/** The last state seen per job, to tell a transition from a repeat. */
@@ -79,6 +76,8 @@ export function cloneJobError(job: CloneJob): string {
function announce(job: CloneJob, lane?: Lane) {
const t = i18n.t.bind(i18n);
const path = job.result?.path || job.target;
// A failure or a stop is told by its chip alone (reason + Retry): a toast
// repeated the same long message on top of the chip, covering its buttons.
if (job.state === "done") {
toast.success(
job.kind === "workspace"
@@ -89,10 +88,6 @@ function announce(job: CloneJob, lane?: Lane) {
})
: t("lanes:cloneJobs.doneSource", { name: job.name, path })
);
} else if (job.state === "cancelled") {
toast.info(t("lanes:cloneJobs.cancelledToast", { name: job.name }));
} else {
toast.error(t("lanes:cloneJobs.failedToast", { name: job.name, message: cloneJobError(job) }));
}
window.dispatchEvent(new CustomEvent<CloneJob>(CLONE_JOB_EVENT, { detail: job }));
if (job.state === "done") {
+48
View File
@@ -0,0 +1,48 @@
/**
* @file gitChanged.ts
* @description One window event, `agenthub:git-changed` `{laneId, action}`,
* fired after EVERY git write the dashboard sends for a workspace
* (`api.lanes.gitAction` fires it, success or failure — a failed shelve or
* a conflicted restore still moved files). Every view that shows that
* workspace's git state — the Changes panel of each repo, the header's facts
* (`useLaneGitFacts`, the rail counts included), the workspace home's branch —
* listens and reads again, so a switch made in one of them is never left
* stale in another for a poll interval. The views do not share a callback;
* this is the bus between them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
export const GIT_CHANGED_EVENT = "agenthub:git-changed";
export interface GitChangedDetail {
laneId: number;
/** The git action that ran (`switch`, `commit`, `shelve`, …). */
action: string;
}
/** Tell every view of `laneId` that its git state may have moved. */
export function emitGitChanged(laneId: number, action: string): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<GitChangedDetail>(GIT_CHANGED_EVENT, { detail: { laneId, action } })
);
}
/**
* Call `fn` after every git write on workspace `laneId`. The latest `fn` is
* used, so a caller may pass an inline closure without resubscribing.
*/
export function useGitChanged(laneId: number, fn: (action: string) => void): void {
const latest = useRef(fn);
latest.current = fn;
useEffect(() => {
const on = (e: Event) => {
const d = (e as CustomEvent<GitChangedDetail>).detail;
if (d && d.laneId === laneId) latest.current(d.action);
};
window.addEventListener(GIT_CHANGED_EVENT, on);
return () => window.removeEventListener(GIT_CHANGED_EVENT, on);
}, [laneId]);
}
+211
View File
@@ -0,0 +1,211 @@
/**
* @file Types of the Changes panel's history reads and multi-commit
* cherry-pick/revert (server/lib/git-history.js, server/lib/git-pick.js),
* plus the small pure helpers the history components share: parsing a search
* box into the log's filters and naming a badge's kind.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { LaneChangeFile } from "./types";
/** A ref pointing at a commit, typed for its badge. */
export interface RefBadge {
kind: "local" | "remote" | "tag" | "head";
name: string;
/** The branch HEAD is on. */
current?: boolean;
}
/** `GET /git/commit` — one commit in full. */
export interface CommitDetails {
available: true;
sha: string;
short: string;
subject: string;
message: string;
author: string;
email: string;
date: string;
committer: string;
committerEmail: string;
committedAt: string;
parents: string[];
badges: RefBadge[];
isHead: boolean;
branches: string[];
tags: string[];
containsTruncated: boolean;
}
/** `GET /git/patch`. */
export interface CommitPatch {
available: true;
sha: string;
patch: string;
omitted: string[];
truncated: boolean;
}
/** One version in `GET /git/file-history`. */
export interface FileVersion {
sha: string;
short: string;
subject: string;
author: string;
date: string;
parents: string[];
/** A, M, R, D, C… */
status: string;
/** The file's name in THIS commit. */
path: string;
/** A rename's old name. */
from?: string;
}
export interface FileHistoryPage {
available: true;
path: string;
commits: FileVersion[];
hasMore: boolean;
}
/** `GET /git/blame-at`. */
export interface BlameAt {
available: true;
path: string;
rev: string;
commits: Record<
string,
{ short: string; author?: string; date?: string; summary?: string; previous?: string }
>;
lines: { sha: string | null; text: string }[];
}
export interface TreeAtEntry {
path: string;
name: string;
dir: boolean;
submodule: boolean;
size: number | null;
}
/** `GET /git/tree-at`. */
export interface TreeAt {
available: true;
rev: string;
path: string;
entries: TreeAtEntry[];
truncated: boolean;
}
export interface CompareCommit {
sha: string;
short: string;
subject: string;
author: string;
date: string;
parents: string[];
}
/** `GET /git/compare` without `path`. */
export interface CompareResult {
available: true;
a: string;
b: string;
mode: "three" | "two";
onlyA: CompareCommit[];
onlyB: CompareCommit[];
commitsTruncated: boolean;
files: (LaneChangeFile & { from?: string })[];
filesTruncated: boolean;
}
/** `GET /git/compare` with `path`. */
export interface CompareFile {
available: true;
file: LaneChangeFile & { secret?: boolean };
}
export interface PlannedCommit {
sha: string;
short: string;
subject: string;
author: string;
date: string;
}
/** `GET /git/pick-plan`. */
export interface PickPlan {
available: true;
op: "cherry-pick" | "revert";
commits: PlannedCommit[];
branch: string | null;
clean: boolean;
repoRoot: boolean;
busy: boolean;
}
/** `GET /git/sequence`. */
export type PickSequence =
| { available: true; active: false }
| {
available: true;
active: true;
operation: "cherry-pick" | "revert";
current: { sha: string; short: string; subject: string } | null;
remaining: { sha: string; short: string; subject: string }[];
done: number;
startedFrom: string | null;
conflicts: string[];
};
/** The answer of a multi-commit pick/revert and of the banner's buttons. */
export interface PickResult {
action: string;
ok: true;
output: string;
from?: string | null;
to?: string | null;
commits?: { sha: string; short: string; subject: string }[];
conflicts: string[];
paused: boolean;
empty?: boolean;
applied?: PlannedCommit[];
}
export type PickAction = "pick-continue" | "pick-skip" | "pick-abort" | "undo-pick";
/** The history's search, as the log's query parameters. */
export interface HistoryFilter {
q?: string;
author?: string;
path?: string;
}
/**
* One search box into the log's filters, like GitLens' search: `author:x`
* and `path:x` (or `file:x`) are their own filters, the rest is message text
* (a hex word also finds that SHA prefix, server-side).
*/
export function parseSearch(text: string): HistoryFilter {
const out: HistoryFilter = {};
const rest: string[] = [];
for (const m of text.matchAll(/(\w+):("[^"]*"|\S+)|("[^"]*"|\S+)/g)) {
if (m[1]) {
const v = m[2]!.replace(/^"|"$/g, "");
const key = m[1].toLowerCase();
if (key === "author" || key === "by") out.author = v;
else if (key === "path" || key === "file") out.path = v;
else rest.push(m[0]);
} else rest.push(m[3]!.replace(/^"|"$/g, ""));
}
const q = rest.join(" ").trim();
if (q) out.q = q.slice(0, 200);
if (out.author) out.author = out.author.slice(0, 200);
return out;
}
/** Selected commits, in the order they appear in the list (newest first). */
export function inListOrder<T extends { sha: string }>(list: T[], picked: Set<string>): T[] {
return list.filter((c) => picked.has(c.sha));
}
+186
View File
@@ -0,0 +1,186 @@
/**
* @file Client calls for the better-conflicts and history-rewrite surface
* (server/lib/git-conflicts.js, server/lib/git-rewrite.js): the merge editor's
* stage read and save, the reflog, remotes, the rewrite plans, and the rewrite
* / remote writes — each a POST /git/<action> like `api.lanes.gitAction`. Kept
* out of api.ts so its own surface stays in one file. Every write fires the
* shared `agenthub:git-changed` event once (`emitGitChanged`, gitChanged.ts),
* as `api.lanes.gitAction` does, so every open git view reads again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { gitUrl, request, type GitTarget } from "./api";
import { emitGitChanged } from "./gitChanged";
/** One conflicted file's three sides and the working file it saves over. */
export interface ConflictSides {
available: boolean;
path: string;
operation: "merge" | "cherry-pick" | "revert" | "rebase" | null;
branch: string | null;
incoming: { sha: string; short: string; subject: string } | null;
base: string | null;
ours: string | null;
theirs: string | null;
working: string | null;
sha: string | null;
usable: boolean;
binary: boolean;
tooBig: boolean;
}
export interface ReflogEntry {
index: number;
selector: string;
sha: string;
short: string;
action: string;
message: string;
time: number | null;
subject: string;
}
export interface GitRemote {
name: string;
url: string;
pushUrl: string;
defaultBranch: string | null;
/** Its fetched remote-tracking branches (every one, a local copy or not). */
branches?: string[];
}
export interface PlanCommit {
sha: string;
short: string;
subject: string;
message: string;
pushed: boolean;
}
interface PlanCommon {
available: boolean;
head: string;
branch: string | null;
subdir: boolean;
operation: string | null;
dirty: boolean;
}
export interface RebasePlan extends PlanCommon {
kind: "rebase";
onto: string;
commits: PlanCommit[];
behind: number;
pushed: number;
}
export interface InteractivePlan extends PlanCommon {
kind: "interactive";
base: string;
merges: boolean;
commits: PlanCommit[];
pushed: number;
}
export interface ResetPlan extends PlanCommon {
kind: "reset";
target: string;
related: boolean;
dropped: number;
added: number;
pushed: number;
}
export interface PushPlan extends PlanCommon {
kind: "push";
remote: string;
remoteBranch: string;
remoteSha: string | null;
ahead: number;
behind: number;
protected: boolean;
allowed: boolean;
}
export type TodoAction = "pick" | "reword" | "squash" | "fixup" | "drop";
export interface TodoEntry {
sha: string;
action: TodoAction;
message?: string;
}
export type ResetMode = "soft" | "mixed" | "keep" | "hard";
export type RewriteAction =
| "rebase"
| "rebase-continue"
| "rebase-skip"
| "rebase-abort"
| "rebase-interactive"
| "reset"
| "undo-rewrite"
| "force-push"
| "remote-branch-delete"
| "remote-add"
| "remote-rename"
| "remote-remove"
| "conflict-save";
export interface RewriteResult {
action: string;
ok: true;
output: string;
before?: string;
after?: string | null;
conflicts?: string[];
stopped?: boolean;
needsForcePush?: boolean;
resolved?: boolean;
}
export const gitRewrite = {
conflict: (t: GitTarget, path: string) =>
request<ConflictSides>(gitUrl(t, "/conflict", new URLSearchParams({ path }))),
reflog: (t: GitTarget, opts: { ref?: string; skip?: number; limit?: number } = {}) => {
const q = new URLSearchParams();
if (opts.ref) q.set("ref", opts.ref);
if (opts.skip) q.set("skip", String(opts.skip));
if (opts.limit) q.set("limit", String(opts.limit));
return request<{ available: boolean; ref?: string; entries: ReflogEntry[]; more: boolean }>(
gitUrl(t, "/reflog", q)
);
},
remotes: (t: GitTarget) =>
request<{ available: boolean; remotes: GitRemote[] }>(gitUrl(t, "/remotes")),
planRebase: (t: GitTarget, onto: string) =>
request<RebasePlan>(gitUrl(t, "/rewrite-plan", new URLSearchParams({ kind: "rebase", onto }))),
planInteractive: (t: GitTarget, base?: string) =>
request<InteractivePlan>(
gitUrl(
t,
"/rewrite-plan",
new URLSearchParams(base ? { kind: "interactive", base } : { kind: "interactive" })
)
),
planReset: (t: GitTarget, target: string) =>
request<ResetPlan>(gitUrl(t, "/rewrite-plan", new URLSearchParams({ kind: "reset", target }))),
planPush: (t: GitTarget) =>
request<PushPlan>(gitUrl(t, "/rewrite-plan", new URLSearchParams({ kind: "push" }))),
/** POST /git/<action>; fires `agenthub:git-changed` once after it answers (ok or not). */
run: async (t: GitTarget, action: RewriteAction, body: Record<string, unknown> = {}) => {
try {
return await request<RewriteResult>(gitUrl(t, `/${action}`), {
method: "POST",
body: JSON.stringify(body),
});
} finally {
emitGitChanged(typeof t === "number" ? t : t.lane, action);
}
},
};
/** The error message of a failed call, for an inline alert. */
export function errText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/** The error code of a failed call (`ApiError.code` / `.status` shape). */
export function errCode(e: unknown): string | undefined {
const c = (e as { code?: unknown })?.code;
return typeof c === "string" ? c : undefined;
}
+8 -3
View File
@@ -368,10 +368,15 @@ function typeRow(options: PaneMenuOption[]): number {
/** The status line a tool's TUI shows only while a turn runs, read off its
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
* tool missing here (omp, Cursor) has no line we know. */
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it.
* Claude ≥ 2.1.3xx keeps `esc to interrupt` only in its footer, which a
* narrow pane, a tip or a notice replaces, so its spinner line counts too:
* a verb, an ellipsis and the turn's time in parentheses
* (`✶ Skedaddling… (41s · ↓ 2.8k tokens)`) — the line a finished turn leaves
* (`✻ Cooked for 1m 2s`) has neither. A tool missing here (omp, Cursor) has
* no line we know. */
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
claude: /\besc to interrupt\b/i,
claude: /\besc to interrupt\b|…\s*\(\s*\d+[smh]\b/i,
codex: /\besc to interrupt\b/i,
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
};
+5 -1
View File
@@ -2892,7 +2892,7 @@ export type LaneGitFacts =
* an older server. */
merging?: boolean;
/** Which one `merging` is about; absent on an older server (= merge). */
operation?: "merge" | "cherry-pick" | "revert" | null;
operation?: "merge" | "cherry-pick" | "revert" | "rebase" | null;
/** Files the merge still holds unmerged, repo-wide. */
conflicts?: number;
/** The branch "vs base" compares against: the lane's own base, else the
@@ -2937,6 +2937,10 @@ export interface LaneCommit {
refs: string[];
/** Parent shas, first parent first — the history's commit graph. */
parents: string[];
/** Author e-mail (the history graph's tooltip). */
email?: string;
/** The refs pointing here, typed for the graph's badges (`gitHistory.ts`). */
badges?: import("./gitHistory").RefBadge[];
}
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.51 (rebuild client to match)
UI build v2.8.55 (rebuild client to match)
</span>
</p>
</div>
+283 -15
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
@@ -1016,6 +1083,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
@@ -1104,6 +1190,44 @@ each call spends the user's Claude quota. Answers `{"message": "..."}`;
for a path git does not report changed, `400 ENOTOOL` without `claude` on PATH,
`502 EAI` when the CLI fails, answers empty or runs past 120 s.
#### Conflicts, reflog, remotes and rewrite plans
```http
GET /api/lanes/:id/git/conflict?path=<path>
GET /api/lanes/:id/git/reflog?ref=HEAD|<branch>&skip=0&limit=50
GET /api/lanes/:id/git/remotes
GET /api/lanes/:id/git/rewrite-plan?kind=rebase&onto=<branch>
GET /api/lanes/:id/git/rewrite-plan?kind=interactive[&base=<sha>]
GET /api/lanes/:id/git/rewrite-plan?kind=reset&target=<sha>
GET /api/lanes/:id/git/rewrite-plan?kind=push
```
Reads (no same-origin guard), all taking `?session=`/`?source=`/`?repo=` like
the other git routes (`server/lib/git-conflicts.js`, `server/lib/git-rewrite.js`).
- **`/git/conflict`** — the 3-way merge editor's input for a path
`git ls-files -u` lists as unmerged right now (else `400 EBADPATH`), with
`/git/file`'s guards (`403 ESECRET`, 1 MB cap): `{operation, branch,
incoming: {sha, short, subject}|null, base, ours, theirs, working, sha,
usable, binary, tooBig}`. Each side is read by its blob id (stages 1/2/3); a
side git does not have is `null`. `sha` is the `baseSha` `conflict-save`
needs. In a rebase `ours` is the branch being rebased onto and `theirs` the
commit being replayed.
- **`/git/reflog`** — `git log -g` of HEAD or of a local branch that exists
(`400 EBADBRANCH`/`ENOBRANCH`), newest first: `{ref, more, entries:
[{index, selector, sha, short, action, message, time, subject}]}`.
- **`/git/remotes`** — `{remotes: [{name, url, pushUrl, defaultBranch,
branches}]}`; an http(s) URL loses its whole userinfo and any other keeps only
the user name, so no password or token is ever answered.
- **`/git/rewrite-plan`** — what a rewrite would do, for the dialog that asks
before it: every answer has `head` (the sha a write must name), `branch`,
`subdir`, `operation`, `dirty`; `rebase` adds `commits[]` (each with
`pushed`), `behind`, `pushed`; `interactive` adds `base`, `merges`,
`commits[]` (with full `message`), `pushed`; `reset` adds `target`,
`related`, `dropped`, `added`, `pushed`; `push` adds `remote`,
`remoteBranch`, `remoteSha` (the lease), `ahead`, `behind`, `protected`,
`allowed`. A bad `kind` is `400 EBADMODE`.
#### Act on a workspace's working copy
```http
@@ -1126,9 +1250,14 @@ cannot collide on `index.lock`.
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
| `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 | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `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. 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"}` |
@@ -1136,9 +1265,27 @@ 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": ["..."]}` |
| `unshelve` | `git stash apply --index <sha>` (retried without `--index` when only the index cannot be rebuilt), then `git stash drop` of that entry when it applied cleanly | `{"sha": "<shelf sha>"}` |
| `shelf-drop` | `git stash drop` of that entry | `{"sha": "<shelf sha>", "confirm": true}` |
| `ignore` | appends one anchored, escaped `/<path>` line per path to the WORKSPACE's own `.gitignore` (created if missing, never a line it already has) — untracked paths only (`400 ETRACKED`), never through a symlink or onto a non-regular file (`400 EBADPATH`) | `{"paths": ["..."]}` |
| `conflict-save` | the 3-way merge editor's result written over the conflicted file through `PUT /git/file`'s guards (`baseSha` → `409 ESTALE`, `403 ESECRET`), then `git add`; a result with a marker left is `400 EMARKERS` before anything is written, unless `resolve: false` (save progress, still conflicted) | `{"path": "...", "content": "...", "baseSha": "<sha from /git/conflict>", "resolve": true}` |
| `rebase` | `git rebase <ref>` (no editor, no autostash, no other branch moved) — a local or remote-tracking branch that exists | `{"onto": "main", "head": "<HEAD the plan showed>", "force": false}` |
| `rebase-continue` / `rebase-skip` | `git rebase --continue` (`409 ECONFLICTS` while anything is unmerged) / `--skip` | — |
| `rebase-abort` | `git rebase --abort` | `{"confirm": true}` |
| `rebase-interactive` | `git rebase -i <base>` with a todo AgentHub builds from the list — no editor opens | `{"head": "<HEAD>", "base": "<sha, optional>", "todo": [{"sha": "<full sha>", "action": "pick\|reword\|squash\|fixup\|drop", "message": "..."}], "force": false, "confirm": true}` |
| `reset` | `git reset --soft\|--mixed\|--keep\|--hard <target>` | `{"target": "<sha>", "mode": "keep", "head": "<HEAD>", "confirm": true, "force": false, "confirmBranch": "<branch, hard only>"}` |
| `undo-rewrite` | `git reset --keep <to>` — puts the branch back where a rewrite found it | `{"to": "<before>", "head": "<after>", "confirm": true}` |
| `force-push` | `git push -u --force-with-lease=refs/heads/<b>:<expect> <remote> HEAD:refs/heads/<b>` — `<b>` is the current branch's upstream branch | `{"expect": "<remote sha the plan showed, or \"\">", "confirm": true}` |
| `remote-branch-delete` | `git push <remote> --delete refs/heads/<branch>` — a fetched remote-tracking branch | `{"branch": "origin/feature", "confirm": true}` |
| `remote-add` / `remote-rename` / `remote-remove` | `git remote add -- <name> <url>` / `rename -- <name> <to>` / `remove -- <name>` | `{"name": "upstream", "url": "git@host:o/r.git"}` / `{"name": "...", "to": "..."}` / `{"name": "...", "confirm": true}` |
```json
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
@@ -1156,9 +1303,91 @@ cannot collide on `index.lock`.
a merge is finished by `merge-commit` — and any path git does not report
changed (`400 EBADPATH`).
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
running agent. There is deliberately no rebase, hard reset or stash here —
a hard `reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
(`400 EDIRTY`) unless `changes` says what happens to the changes: neither
can invent a merge commit or a conflict underneath a running agent. A
rebase, a reset or a force push happens only as its own action (below).
- **Switch with changes.** `changes: "carry"` runs a plain `git switch`
(never `--force`, `--merge` or `--discard-changes`); when git refuses
because a carried file would be overwritten the answer is
`409 ECARRYCONFLICT` with `files: ["…"]` (git's own list), and nothing has
moved. `changes: "shelve"` shelves the changes (as `shelve` below, with an
optional `note`) and then switches; if the switch fails the shelf is
restored at once and git's error is returned. Every check that needs no
switch attempt (`ENOBRANCH`, `EBRANCHBUSY`, …) runs before the shelf is
made. Any other `changes` value is `400 EBADCHANGES`. A successful switch
answers `shelves: [...]` — the TARGET branch's shelves, which the UI offers
back (asking, never applying one itself) — and `shelved: {...}` when it
made one. `branch-create` with `switch: true` is `409 EMERGING` while a
merge, cherry-pick, revert or rebase is open, and `409 ECARRYCONFLICT` if a
`base` other than HEAD clashes with the changes.
- **Shelves** are git stash entries AgentHub made, recognised by the message
prefix `agenthub-shelf: ` — any other stash is the user's and is never
listed or touched. They are listed by `GET /api/lanes/:id/git/shelves`
(`{available, shelves: [{ref, sha, branch, note, createdAt, files: [{path,
status}]}]}`, newest first; untracked files as `??`) and addressed in
writes by `sha`, the stash commit, never by `stash@{n}` (a terminal stash
would shift it); an abbreviation works, a sha no shelf has is
`409 ENOSHELF`. A stash is repo-wide, so `shelve`, `unshelve` and
`shelf-drop` need a workspace at the repo root (`400 ESUBDIR`), and
`shelve`/`unshelve` are `409 EMERGING` during a merge, cherry-pick, revert
or rebase. `shelve` on a clean tree is `400 ENOCHANGES`; its `paths` go
through the same `git status` check as `discard`. `unshelve` refuses a
dirty tree (`400 EDIRTY`) so a restore never mixes with new work; a clean
apply drops the entry (`kept: false`), a conflicted one KEEPS it and
answers `conflicts: [...]`, `kept: true` — the files are resolved with
`resolve` (`ours` = the branch, `theirs` = the shelf; there is no merge to
commit afterwards, the result is an uncommitted change) and the shelf is
deleted by hand. `shelf-drop` is irreversible and needs `confirm: true`
(`400 ECONFIRM`). Nothing else ever drops a shelf.
- **A working agent.** When an agent of the workspace is working (a lane no
pane holds: `status: running` from its hooks; a lane a pane holds: one of
its own sessions has a main agent `working`), `shelve`, `unshelve` and a
`switch` that carries or shelves uncommitted changes answer
`409 EAGENTRUNNING` until resent with `confirm: true` — they move files
under it.
- **History rewrites** (`server/lib/git-rewrite.js`) are allowed because each
one's recovery is a button too. Every one runs only from a repo-root
workspace (`400 ESUBDIR`), never while a merge, cherry-pick, revert or rebase
is open (`409 EMERGING`), and names the full `head` sha the plan showed
(`409 ESTALE` once HEAD moved, `400 EBADSHA` without it). Rewriting a commit
any remote-tracking branch already holds is `409 EPUSHED` unless the body
says `force: true` (the UI shows that a force-push will be needed). Each
answers `before`/`after`; `undo-rewrite` (`reset --keep <before>`, only while
HEAD is still `after`) undoes it.
- `rebase` and `rebase-interactive` need a clean tree (`400 EDIRTY`). A stop
is a **200** with `conflicts[]` and `stopped: true`; while a rebase is open
`GET /git` reports `merging: true, operation: "rebase"`, every write except
`fetch`, `stage`, `unstage`, `discard`, `ignore`, `resolve`,
`conflict-save` and the rebase's own steps is `409 EMERGING`, and the
banner's `merge-commit` / `merge-abort` act as `rebase-continue` /
`rebase-abort` — a rebase started in a terminal included.
`rebase-continue`/`-skip`/`-abort` with no rebase open are `409 ENOREBASE`.
- `rebase-interactive`: `todo` lists EXACTLY the commits of `base..HEAD`
(default base: the parent of the oldest commit no remote has) by full sha,
each once, in the new order, else `400 EBADTODO`; a merge commit in the
range is `400 EMERGECOMMIT`; a list that changes nothing is
`400 ENOCHANGES`; a reword needs a message (`400 EBADMESSAGE`). AgentHub
writes the todo and the messages as 0600 files in the repo's own git dir
(as the folder's account), hands git `cp` as its sequence editor and `true`
as its editor; a squash becomes `fixup` + `exec git commit --amend -F <file>`
with the combined (or given) message, so no request text is ever shell.
- `reset`: `target` must be an ancestor or descendant of HEAD
(`400 ENOTRELATED`), `mode` one of soft/mixed/keep/hard (`400 EBADMODE`);
`hard` only on a clean tree (`400 EDIRTY`) and with `confirmBranch` equal to
the current branch name (`400 ECONFIRM`). `keep` refuses (git's own `409`)
rather than overwrite a local change.
- `force-push`: HEAD only, to its upstream branch, with a lease on `expect` —
`409 ESTALE` when the remote moved since the fetch the plan read;
`main`/`master`/`development`/`develop` are `400 EPROTECTED` unless the
server runs with `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`; a detached HEAD is
`400 EDETACHED`.
- `remote-branch-delete` refuses a protected name (`400 EPROTECTED`) and the
remote's default branch (`refs/remotes/<r>/HEAD`, `400 EDEFAULTBRANCH`);
a branch not fetched is `400 ENOBRANCH`.
- `remote-add` takes its URL through `publish`'s allowlist (`400 EBADURL`)
and a name of letters, digits, `.`, `_`, `-` (`400 EBADREMOTE`); a taken
name is `400 EREMOTEEXISTS`; `remote-remove` needs `confirm: true`.
A stored git credential still goes only to its own host.
- **Cherry-pick and revert** follow merge: a commit named by hex sha that
exists (`400 EBADSHA`) and is not a merge commit (`400 EMERGECOMMIT`), a
clean tree, a repo-root workspace. A conflict is a **200** with
@@ -1168,8 +1397,27 @@ cannot collide on `index.lock`.
revert whose changes are already there is aborted again and answers
`409 EEMPTY`, never left open; one git could not commit (no identity, a
failing signer) is undone the same way and answers git's own error. A
rebase stopped in a terminal makes every history action, `merge-commit` and
`merge-abort` `409 EMERGING` — it is finished where it started.
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
@@ -1215,11 +1463,31 @@ cannot collide on `index.lock`.
`git status` check; an untracked file has no hunks (`400 EUNTRACKED`). A
hunk that no longer applies (e.g. part of it is already staged) is
`409 EGIT`.
- `branch-create` never switches — the usual reason to create one from a workspace
- `branch-create` never switches unless `switch: true` — the usual reason to create one from a workspace
is to spawn a sibling worktree workspace, and moving this pane's HEAD under a
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
@@ -1233,7 +1501,7 @@ cannot collide on `index.lock`.
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
`ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`, `EBADURL`, `EREMOTEEXISTS`,
`ENOCOMMITS`); git itself exiting
`ENOCOMMITS`, `EBADCHANGES`); git itself exiting
non-zero (a rejected push, a diverged pull) is
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
rejection reads better in git's words. Anything else (git not installed, a
+143 -16
View File
File diff suppressed because one or more lines are too long
+731 -12
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.51",
"version": "2.8.55",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.51",
"version": "2.8.55",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.51",
"version": "2.8.55",
"bin": {
"agenthub": "bin/agenthub.js"
},
+2 -1
View File
@@ -546,6 +546,7 @@ Request body shape:
| `GET` | `/api/lanes/:id/git/download?path=` | One working file of any type as an `application/octet-stream` attachment (ASCII + `filename*=UTF-8''` name) under `Content-Security-Policy: default-src 'none'; sandbox`, `nosniff`, `no-store` (`server/lib/lane-file-transfer.js`). The file read's guards, 512 MB cap. `403 ESECRET`/`EFOREIGN`, `400 EBADPATH`/`ENOFILE`/`ETOOBIG`. |
| `POST` | `/api/lanes/:id/git/upload?dir=` | Same-origin guarded. Raw body, name in the URI-encoded `X-File-Name` header, `dir` workspace-relative (`""` = root); `201 {path}`. Creates only (`409 EEXISTS`, a symlink at the name included), into an existing folder (`400 ENOFILE`), never into `.git` (`400 EBADPATH`), one plain name ≤ 255 bytes and not `.git` (`400 EBADNAME`), never a secret name (`403 ESECRET`), 25 MB cap (`413 ETOOBIG`), `403 EFOREIGN`. Under S3 written as the folder's member via `write-as`. |
| `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. |
| `GET` | `/api/lanes/:id/git/shelves` | The workspace repo's shelves — the stash entries AgentHub made (`agenthub-shelf:` prefix), each `{ref, sha, branch, note, createdAt, files}`; other stashes are never listed. Read-only; `?session`/`?source`/`?repo` like the other git reads. |
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
@@ -563,7 +564,7 @@ Request body shape:
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. |
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. No rebase/hard reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree unless `changes: "carry"` — `409 ECARRYCONFLICT` with `files` when git would overwrite one — or `changes: "shelve"`; answers the target branch's `shelves`), `branch-create` (never switches unless `switch: true`) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. `shelve` (`{note?, paths?}`: `stash push --include-untracked` under an `agenthub-shelf:` message), `unshelve` (`{sha}`: clean tree only, `apply --index`, dropped only when clean, a conflict keeps it), `shelf-drop` (`{sha, confirm:true}`) — repo root only, never mid-operation, only entries AgentHub made (`GET /api/lanes/:id/git/shelves` lists them); `409 EAGENTRUNNING` until `confirm:true` while the workspace's agent is working. No rebase/hard reset by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
an existing absolute git repository. The response workspace starts as
+1 -1
View File
@@ -291,7 +291,7 @@ describe("runGitAction", () => {
beforeEach(freshFixture);
it("rejects an unknown action", async () => {
await assert.rejects(() => runGitAction(LANE, "rebase"), { code: "EBADACTION" });
await assert.rejects(() => runGitAction(LANE, "frobnicate"), { code: "EBADACTION" });
});
it("commits everything with the given message", async () => {
+350
View File
@@ -0,0 +1,350 @@
/**
* @file The history reads and the multi-commit cherry-pick/revert against a
* REAL repo: log paging, parents, ref badges, all-branches and search (text,
* author, path, SHA prefix); commit details and patch (a secret left out);
* file history across a rename; blame at a revision; tree at a revision (a
* secret hidden, its content refused); comparing two refs both ways; and
* `cherry-pick`/`revert` with `shas` — order, success, a conflict then
* continue / skip / abort, and undo-pick (refused once stale or pushed).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { runGitAction } = require("../lib/git-ops");
const history = require("../lib/git-history");
const { readFileAt } = require("../lib/git-diff");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ghist-"));
const REPO = path.join(ROOT, "repo");
const REMOTE = path.join(ROOT, "remote.git");
const env = () => {
const e = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
delete e.GIT_DIR;
delete e.GIT_WORK_TREE;
delete e.GIT_INDEX_FILE;
return e;
};
const g = (...args) => execFileSync("git", args, { cwd: REPO, encoding: "utf8", env: env() });
const write = (file, body) => {
fs.mkdirSync(path.dirname(path.join(REPO, file)), { recursive: true });
fs.writeFileSync(path.join(REPO, file), body);
};
const sha = (ref) => g("rev-parse", ref).trim();
const subjects = (n) => g("log", `-${n}`, "--format=%s").trim().split("\n");
// main: init, main-1 ; feat (from init): f1 (adds b), f2 (a.txt conflicts), f3 (adds c)
beforeEach(() => {
fs.rmSync(REPO, { recursive: true, force: true });
fs.rmSync(REMOTE, { recursive: true, force: true });
fs.mkdirSync(REPO, { recursive: true });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@h");
g("config", "user.name", "Tester");
write("a.txt", "base\n");
write("docs/old.md", "one\ntwo\nthree\n");
g("add", "-A");
g("commit", "-qm", "init");
g("tag", "v1");
g("switch", "-qc", "feat");
write("b.txt", "b\n");
g("add", "-A");
g("commit", "-qm", "f1 adds b");
write("a.txt", "theirs\n");
g("commit", "-qam", "f2 edits a");
write("c.txt", "c\n");
g("add", "-A");
g("commit", "-qm", "f3 adds c");
g("switch", "-q", "main");
write("a.txt", "ours\n");
g("commit", "-qam", "main-1");
});
describe("listHistory", () => {
it("pages with parents, badges and hasMore", async () => {
const p1 = await history.listHistory(REPO, { limit: 1 });
assert.equal(p1.commits.length, 1);
assert.equal(p1.hasMore, true);
assert.equal(p1.commits[0].subject, "main-1");
assert.deepEqual(p1.commits[0].parents, [sha("main~1")]);
assert.deepEqual(p1.commits[0].badges, [{ kind: "local", name: "main", current: true }]);
const p2 = await history.listHistory(REPO, { limit: 1, skip: 1 });
assert.equal(p2.commits[0].subject, "init");
assert.deepEqual(p2.commits[0].badges, [{ kind: "tag", name: "v1" }]);
assert.equal(p2.hasMore, false);
});
it("reads every branch with all=1", async () => {
const { commits } = await history.listHistory(REPO, { all: "1" });
assert.equal(commits.length, 5);
const f3 = commits.find((c) => c.subject === "f3 adds c");
assert.deepEqual(f3.badges, [{ kind: "local", name: "feat" }]);
});
it("searches by message, author, path and SHA prefix, all literal", async () => {
const byText = await history.listHistory(REPO, { all: "1", q: "ADDS" });
assert.deepEqual(byText.commits.map((c) => c.subject).sort(), ["f1 adds b", "f3 adds c"]);
assert.equal(byText.filtered, true);
assert.equal((await history.listHistory(REPO, { all: "1", q: "a.d" })).commits.length, 0);
assert.equal((await history.listHistory(REPO, { author: "nobody" })).commits.length, 0);
assert.equal((await history.listHistory(REPO, { author: "test" })).commits.length, 2);
const byPath = await history.listHistory(REPO, { all: "1", path: "c.txt" });
assert.deepEqual(
byPath.commits.map((c) => c.subject),
["f3 adds c"]
);
const f2 = sha("feat~1");
const bySha = await history.listHistory(REPO, { q: f2.slice(0, 8) });
assert.equal(bySha.commits[0].sha, f2);
await assert.rejects(history.listHistory(REPO, { path: "../x" }), { code: "EBADPATH" });
await assert.rejects(history.listHistory(REPO, { q: "x".repeat(300) }), { code: "EBADQUERY" });
});
});
describe("commit details, patch, file history, blame, tree, compare", () => {
it("details name containing branches and tags", async () => {
const d = await history.commitDetails(REPO, sha("main~1"));
assert.equal(d.subject, "init");
assert.equal(d.author, "Tester");
assert.ok(d.branches.includes("main") && d.branches.includes("feat"));
assert.deepEqual(d.tags, ["v1"]);
await assert.rejects(history.commitDetails(REPO, "--all"), { code: "EBADREV" });
await assert.rejects(history.commitDetails(REPO, "a..b"), { code: "EBADREV" });
});
it("copies a patch without a secret file's diff", async () => {
write(".claude/settings.local.json", '{"key":"sk-secret"}\n');
write("ok.txt", "fine\n");
g("add", "-A", "-f");
g("commit", "-qm", "with secret");
const p = await history.commitPatch(REPO, "HEAD");
assert.match(p.patch, /Subject: \[PATCH\] with secret/);
assert.match(p.patch, /ok\.txt/);
assert.doesNotMatch(p.patch, /sk-secret/);
assert.deepEqual(p.omitted, [".claude/settings.local.json"]);
});
it("follows a file across a rename", async () => {
g("mv", "docs/old.md", "docs/new.md");
g("commit", "-qm", "rename");
write("docs/new.md", "one\nTWO\nthree\n");
g("commit", "-qam", "edit new");
const h = await history.fileHistory(REPO, "docs/new.md");
assert.deepEqual(
h.commits.map((c) => [c.subject, c.status, c.path]),
[
["edit new", "M", "docs/new.md"],
["rename", "R", "docs/new.md"],
["init", "A", "docs/old.md"],
]
);
assert.equal(h.commits[1].from, "docs/old.md");
});
it("blames at a revision with each line's text", async () => {
write("docs/old.md", "one\nTWO\nthree\n");
g("commit", "-qam", "edit");
const now = await history.blameAt(REPO, "docs/old.md", "HEAD");
assert.deepEqual(
now.lines.map((l) => [l.text, now.commits[l.sha].summary]),
[
["one", "init"],
["TWO", "edit"],
["three", "init"],
]
);
assert.equal(now.commits[sha("HEAD")].previous, sha("HEAD~1"));
const before = await history.blameAt(REPO, "docs/old.md", "HEAD~1");
assert.equal(before.lines[1].text, "two");
await assert.rejects(history.blameAt(REPO, "nope.md", "HEAD"), { code: "ENOFILE" });
});
it("lists a tree at a revision, a secret hidden and refused", async () => {
write(".claude/settings.local.json", '{"key":"sk"}\n');
g("add", "-A", "-f");
g("commit", "-qm", "secret");
const root = await history.treeAt(REPO, "HEAD");
assert.deepEqual(
root.entries.map((e) => [e.name, e.dir]),
[
[".claude", true],
["docs", true],
["a.txt", false],
]
);
const claude = await history.treeAt(REPO, "HEAD", ".claude");
assert.deepEqual(claude.entries, []);
await assert.rejects(readFileAt(REPO, ".claude/settings.local.json", sha("HEAD")), {
code: "ESECRET",
});
const docs = await history.treeAt(REPO, "v1", "docs");
assert.deepEqual(
docs.entries.map((e) => e.path),
["docs/old.md"]
);
});
it("compares two refs, three-dot and two-dot", async () => {
const c = await history.compareRefs(REPO, { a: "main", b: "feat" });
assert.deepEqual(
c.onlyA.map((x) => x.subject),
["main-1"]
);
assert.deepEqual(
c.onlyB.map((x) => x.subject),
["f3 adds c", "f2 edits a", "f1 adds b"]
);
// Three-dot: what feat adds since the fork — a.txt, b.txt, c.txt.
assert.deepEqual(c.files.map((f) => f.path).sort(), ["a.txt", "b.txt", "c.txt"]);
const one = await history.compareRefs(REPO, { a: "main", b: "feat", path: "a.txt" });
assert.ok(one.file.hunks[0].lines.some((l) => l.type === "add" && l.text === "theirs"));
assert.ok(one.file.hunks[0].lines.some((l) => l.type === "del" && l.text === "base"));
const two = await history.compareRefs(REPO, {
a: "main",
b: "feat",
mode: "two",
path: "a.txt",
});
assert.ok(two.file.hunks[0].lines.some((l) => l.type === "del" && l.text === "ours"));
await assert.rejects(history.compareRefs(REPO, { a: "main", b: "-x" }), { code: "EBADREV" });
});
});
describe("multi-commit cherry-pick / revert", () => {
it("previews the plan oldest first and refuses a merge commit", async () => {
const plan = await history.pickPreview(REPO, "cherry-pick", [sha("feat"), sha("feat~2")]);
assert.deepEqual(
plan.commits.map((c) => c.subject),
["f1 adds b", "f3 adds c"]
);
assert.equal(plan.branch, "main");
assert.equal(plan.clean, true);
const rev = await history.pickPlan(REPO, "revert", [sha("feat~2"), sha("feat")]);
assert.deepEqual(
rev.map((c) => c.subject),
["f3 adds c", "f1 adds b"]
);
g("merge", "-q", "--no-edit", "--no-ff", "-X", "ours", "feat");
await assert.rejects(history.pickPlan(REPO, "cherry-pick", [sha("HEAD")]), {
code: "EMERGECOMMIT",
});
await assert.rejects(history.pickPlan(REPO, "cherry-pick", ["zz"]), { code: "EBADSHA" });
});
it("picks several commits in order and undoes them", async () => {
const from = sha("HEAD");
const r = await runGitAction(REPO, "cherry-pick", { shas: [sha("feat"), sha("feat~2")] });
assert.equal(r.paused, false);
assert.equal(r.from, from);
assert.deepEqual(subjects(2), ["f3 adds c", "f1 adds b"]);
assert.deepEqual(
r.commits.map((c) => c.subject),
["f1 adds b", "f3 adds c"]
);
await assert.rejects(runGitAction(REPO, "undo-pick", { from, to: r.to }), {
code: "ECONFIRM",
});
const u = await runGitAction(REPO, "undo-pick", { from, to: r.to, confirm: true });
assert.equal(u.undone.length, 2);
assert.equal(sha("HEAD"), from);
// Soft: the picked changes stay staged.
assert.match(g("status", "--porcelain"), /A {2}b\.txt/);
});
it("refuses undo once HEAD moved or the commits are pushed", async () => {
const from = sha("HEAD");
const r = await runGitAction(REPO, "cherry-pick", { shas: [sha("feat~2")] });
write("z.txt", "z\n");
g("add", "-A");
g("commit", "-qm", "later");
await assert.rejects(runGitAction(REPO, "undo-pick", { from, to: r.to, confirm: true }), {
code: "ESTALE",
});
g("reset", "-q", "--hard", r.to);
execFileSync("git", ["init", "-q", "--bare", REMOTE], { env: env() });
g("remote", "add", "origin", REMOTE);
g("push", "-q", "origin", "main");
await assert.rejects(runGitAction(REPO, "undo-pick", { from, to: r.to, confirm: true }), {
code: "EPUSHED",
});
});
it("stops on a conflict, then continue finishes the rest", async () => {
const r = await runGitAction(REPO, "cherry-pick", {
shas: [sha("feat~2"), sha("feat~1"), sha("feat")],
});
assert.equal(r.paused, true);
assert.deepEqual(r.conflicts, ["a.txt"]);
const st = await history.sequenceState(REPO);
assert.equal(st.active, true);
assert.equal(st.operation, "cherry-pick");
assert.equal(st.current.subject, "f2 edits a");
assert.equal(st.done, 1);
assert.deepEqual(
st.remaining.map((x) => x.subject),
["f3 adds c"]
);
await assert.rejects(runGitAction(REPO, "pick-continue", {}), { code: "ECONFLICTS" });
await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" });
const c = await runGitAction(REPO, "pick-continue", {});
assert.equal(c.paused, false);
assert.deepEqual(subjects(3), ["f3 adds c", "f2 edits a", "f1 adds b"]);
assert.equal((await history.sequenceState(REPO)).active, false);
});
it("skips the conflicted commit, or aborts back to the start", async () => {
const start = sha("HEAD");
await runGitAction(REPO, "cherry-pick", { shas: [sha("feat~2"), sha("feat~1"), sha("feat")] });
await assert.rejects(runGitAction(REPO, "pick-skip", {}), { code: "ECONFIRM" });
const s = await runGitAction(REPO, "pick-skip", { confirm: true });
assert.equal(s.paused, false);
assert.deepEqual(subjects(2), ["f3 adds c", "f1 adds b"]);
g("reset", "-q", "--hard", start);
await runGitAction(REPO, "cherry-pick", { shas: [sha("feat~2"), sha("feat~1")] });
await assert.rejects(runGitAction(REPO, "pick-abort", {}), { code: "ECONFIRM" });
await runGitAction(REPO, "pick-abort", { confirm: true });
assert.equal(sha("HEAD"), start);
assert.equal((await history.sequenceState(REPO)).active, false);
await assert.rejects(runGitAction(REPO, "pick-continue", {}), { code: "ENOMERGE" });
});
it("reverts several commits newest first, and refuses a dirty tree", async () => {
g("switch", "-q", "feat");
await runGitAction(REPO, "revert", { shas: [sha("feat~2"), sha("feat")] });
assert.deepEqual(subjects(2), ['Revert "f1 adds b"', 'Revert "f3 adds c"']);
write("dirty.txt", "x\n");
await assert.rejects(runGitAction(REPO, "revert", { shas: [sha("HEAD")] }), {
code: "EDIRTY",
});
});
it("keeps the single-sha body on its own path", async () => {
const r = await runGitAction(REPO, "cherry-pick", { sha: sha("feat~2") });
assert.deepEqual(r.conflicts, []);
assert.equal(r.from, undefined);
assert.equal(subjects(1)[0], "f1 adds b");
});
it("refuses every pick action while a rebase is open, and never reports it as a pick", async () => {
// main-1 and feat's f2 both edit a.txt: the rebase stops on it.
assert.throws(() => g("rebase", "feat"));
for (const [action, body] of [
["cherry-pick", { shas: [sha("feat~2")] }],
["pick-continue", {}],
["pick-skip", { confirm: true }],
["pick-abort", { confirm: true }],
["undo-pick", { from: sha("feat~1"), to: sha("feat"), confirm: true }],
]) {
await assert.rejects(runGitAction(REPO, action, body), { code: "EMERGING" }, action);
}
assert.equal((await history.sequenceState(REPO)).active, false);
g("rebase", "--abort");
});
});
+3 -1
View File
@@ -272,6 +272,7 @@ describe("review fixes", () => {
);
});
// merge-commit is the banner's Continue now (git-rewrite.js), so it is not here.
it("a rebase started in a terminal blocks every history action", async () => {
g("switch", "-q", "feat");
const env = { ...process.env, GIT_SEQUENCE_EDITOR: "sed -i 1s/^pick/edit/" };
@@ -281,7 +282,8 @@ describe("review fixes", () => {
["undo-commit", { confirm: true, sha: sha("HEAD") }],
["amend", { message: "x", sha: sha("HEAD") }],
["cherry-pick", { sha: sha("main") }],
["merge-commit", {}],
["commit", { message: "x" }],
["switch", { branch: "main" }],
]) {
await assert.rejects(runGitAction(REPO, action, body), { code: "EMERGING" });
}
+333
View File
@@ -0,0 +1,333 @@
/**
* @file Shelves and switching with uncommitted changes (git-ops.js), against
* REAL temp repos: create & switch, switch carrying the changes (and the
* ECARRYCONFLICT refusal with git's file list), shelve & switch (and the
* shelf going straight back when the switch fails), shelve / unshelve /
* shelf-drop with their guards (repo root, no merge in progress, a clean tree
* for a restore, confirm for a delete, a working agent's confirm), sha
* addressing, and that a stash AgentHub did not make is never listed or
* touched.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { runGitAction, listShelves } = require("../lib/git-ops");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-shelves-"));
const REPO = path.join(ROOT, "repo");
const g = (...args) => {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
};
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
const exists = (file) => fs.existsSync(path.join(REPO, file));
const branch = () => g("branch", "--show-current").trim();
const status = () => g("status", "--porcelain").trim();
const act = (action, body, opts) => runGitAction(REPO, action, body, opts);
beforeEach(() => {
fs.rmSync(REPO, { recursive: true, force: true });
fs.mkdirSync(REPO, { recursive: true });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@h");
g("config", "user.name", "t");
fs.mkdirSync(path.join(REPO, "sub"));
write("a.txt", "base\n");
write("shared.txt", "base\n");
write("sub/s.txt", "s\n");
g("add", "-A");
g("commit", "-qm", "init");
// `feat` changes shared.txt, so carrying an edit of it across is refused.
g("switch", "-qc", "feat");
write("shared.txt", "feat\n");
g("commit", "-qam", "feat");
g("switch", "-q", "main");
});
describe("branch-create with switch", () => {
it("creates the branch, moves to it and carries the uncommitted changes", async () => {
write("a.txt", "wip\n");
write("new.txt", "n\n");
const r = await act("branch-create", { branch: "fix/x", switch: true });
assert.equal(r.branch, "fix/x");
assert.equal(branch(), "fix/x");
assert.equal(read("a.txt"), "wip\n");
assert.equal(read("new.txt"), "n\n");
});
it("plain create still never switches", async () => {
await act("branch-create", { branch: "side" });
assert.equal(branch(), "main");
});
it("is EMERGING while a merge is open", async () => {
write("shared.txt", "main\n");
g("commit", "-qam", "main");
await act("merge", { branch: "feat" });
await assert.rejects(act("branch-create", { branch: "y", switch: true }), {
code: "EMERGING",
});
});
});
describe("switch with changes", () => {
it("still refuses a dirty tree without `changes`, and an unknown value", async () => {
write("a.txt", "wip\n");
await assert.rejects(act("switch", { branch: "feat" }), { code: "EDIRTY" });
await assert.rejects(act("switch", { branch: "feat", changes: "force" }), {
code: "EBADCHANGES",
});
});
it("carry brings the changes along", async () => {
write("a.txt", "wip\n");
const r = await act("switch", { branch: "feat", changes: "carry" });
assert.equal(branch(), "feat");
assert.equal(read("a.txt"), "wip\n");
assert.deepEqual(r.shelves, []);
});
it("carry answers ECARRYCONFLICT with the files git would overwrite", async () => {
write("shared.txt", "mine\n");
await assert.rejects(act("switch", { branch: "feat", changes: "carry" }), (e) => {
assert.equal(e.code, "ECARRYCONFLICT");
assert.deepEqual(e.files, ["shared.txt"]);
return true;
});
assert.equal(branch(), "main");
assert.equal(read("shared.txt"), "mine\n");
});
it("shelve sets the changes aside, switches, and the shelf comes back on the way home", async () => {
write("shared.txt", "mine\n");
write("new.txt", "n\n");
const r = await act("switch", { branch: "feat", changes: "shelve", note: "half done" });
assert.equal(branch(), "feat");
assert.equal(status(), "");
assert.equal(r.shelved.branch, "main");
assert.equal(r.shelved.note, "half done");
assert.deepEqual(r.shelves, []);
const back = await act("switch", { branch: "main" });
assert.equal(back.shelves.length, 1);
assert.equal(back.shelves[0].sha, r.shelved.sha);
const u = await act("unshelve", { sha: r.shelved.sha });
assert.equal(u.kept, false);
assert.deepEqual(u.conflicts, []);
assert.equal(read("shared.txt"), "mine\n");
assert.equal(read("new.txt"), "n\n");
assert.deepEqual(await listShelves(REPO), []);
});
it("refuses before shelving when the target cannot be switched to", async () => {
write("a.txt", "wip\n");
await assert.rejects(act("switch", { branch: "nope", changes: "shelve" }), {
code: "ENOBRANCH",
});
assert.deepEqual(await listShelves(REPO), []);
assert.equal(read("a.txt"), "wip\n");
});
it("leaves nothing moved when the shelf cannot even be made", async () => {
write("a.txt", "wip\n");
fs.writeFileSync(path.join(REPO, ".git", "index.lock"), "");
try {
await assert.rejects(act("switch", { branch: "feat", changes: "shelve" }));
} finally {
fs.rmSync(path.join(REPO, ".git", "index.lock"), { force: true });
}
assert.equal(branch(), "main");
assert.equal(read("a.txt"), "wip\n");
assert.deepEqual(await listShelves(REPO), []);
});
it("puts the shelf straight back when the switch itself fails", async () => {
write("a.txt", "wip\n");
write("new.txt", "n\n");
// A branch only the remote has: the switch must create refs/heads/rfeat,
// and a stale lock on it makes git refuse AFTER the shelf was made.
g("remote", "add", "origin", path.join(ROOT, "nowhere.git"));
g("update-ref", "refs/remotes/origin/rfeat", "feat");
fs.writeFileSync(path.join(REPO, ".git", "refs", "heads", "rfeat.lock"), "");
try {
await assert.rejects(act("switch", { branch: "origin/rfeat", changes: "shelve" }), (e) => {
assert.notEqual(e.code, "ESHELVED");
return true;
});
} finally {
fs.rmSync(path.join(REPO, ".git", "refs", "heads", "rfeat.lock"), { force: true });
}
assert.equal(branch(), "main");
assert.equal(read("a.txt"), "wip\n");
assert.equal(read("new.txt"), "n\n");
assert.deepEqual(await listShelves(REPO), []);
});
it("restores a shelf whose stash failed half way (HEAD locked)", async () => {
write("a.txt", "wip\n");
fs.writeFileSync(path.join(REPO, ".git", "HEAD.lock"), "");
try {
await assert.rejects(act("switch", { branch: "feat", changes: "shelve" }));
} finally {
fs.rmSync(path.join(REPO, ".git", "HEAD.lock"), { force: true });
}
assert.equal(branch(), "main");
assert.equal(read("a.txt"), "wip\n");
assert.deepEqual(await listShelves(REPO), []);
});
it("asks for confirm while the lane's agent is working", async () => {
write("a.txt", "wip\n");
await assert.rejects(
act("switch", { branch: "feat", changes: "carry" }, { agentWorking: true }),
{ code: "EAGENTRUNNING" }
);
await act(
"switch",
{ branch: "feat", changes: "carry", confirm: true },
{ agentWorking: true }
);
assert.equal(branch(), "feat");
});
});
describe("shelves", () => {
it("shelves everything (untracked included) with a note, and lists it", async () => {
write("a.txt", "wip\n");
write("new.txt", "n\n");
const r = await act("shelve", { note: "try\nagain" });
assert.equal(status(), "");
assert.equal(r.shelf.note, "try again");
const [s] = await listShelves(REPO);
assert.equal(s.sha, r.shelf.sha);
assert.equal(s.branch, "main");
assert.match(s.ref, /^stash@\{0\}$/);
assert.ok(!Number.isNaN(Date.parse(s.createdAt)));
assert.deepEqual(s.files.map((f) => `${f.status} ${f.path}`).sort(), ["?? new.txt", "M a.txt"]);
});
it("shelves only the ticked paths", async () => {
write("a.txt", "wip\n");
write("sub/s.txt", "changed\n");
write("new*.txt", "glob-looking name\n");
write("new1.txt", "untouched untracked\n");
const r = await act("shelve", { paths: ["a.txt", "new*.txt"] });
assert.deepEqual(
r.shelf.files.map((f) => f.path),
["a.txt", "new*.txt"]
);
assert.equal(read("a.txt"), "base\n");
// Literal: `new*.txt` took itself only, never new1.txt.
assert.equal(exists("new*.txt"), false);
assert.equal(read("new1.txt"), "untouched untracked\n");
assert.equal(read("sub/s.txt"), "changed\n");
await assert.rejects(act("shelve", { paths: ["../x"] }), { code: "EBADPATH" });
});
it("refuses a clean tree, a subdirectory lane, and a merge in progress", async () => {
await assert.rejects(act("shelve", {}), { code: "ENOCHANGES" });
write("a.txt", "wip\n");
await assert.rejects(runGitAction(path.join(REPO, "sub"), "shelve", {}), {
code: "ESUBDIR",
});
g("checkout", "--", "a.txt");
write("shared.txt", "main\n");
g("commit", "-qam", "main");
await act("merge", { branch: "feat" });
await assert.rejects(act("shelve", {}), { code: "EMERGING" });
});
it("restores staged changes staged, and refuses a dirty tree", async () => {
write("a.txt", "staged\n");
g("add", "a.txt");
const { shelf } = await act("shelve", {});
write("sub/s.txt", "new work\n");
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
g("checkout", "--", "sub/s.txt");
await act("unshelve", { sha: shelf.sha });
assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt");
});
it("keeps a conflicted shelf and lets `resolve` settle its files", async () => {
write("shared.txt", "mine\n");
const { shelf } = await act("shelve", {});
write("shared.txt", "theirs on main\n");
g("commit", "-qam", "moved on");
const r = await act("unshelve", { sha: shelf.sha });
assert.equal(r.kept, true);
assert.deepEqual(r.conflicts, ["shared.txt"]);
assert.equal((await listShelves(REPO)).length, 1);
// `theirs` of a stash apply is the shelf.
await act("resolve", { path: "shared.txt", side: "theirs" });
assert.equal(read("shared.txt"), "mine\n");
assert.equal(g("diff", "--name-only", "--diff-filter=U").trim(), "");
// The kept shelf is deleted by hand.
await act("shelf-drop", { sha: shelf.sha, confirm: true });
assert.deepEqual(await listShelves(REPO), []);
});
it("shelf-drop needs confirm, and a sha no shelf has is ENOSHELF", async () => {
write("a.txt", "wip\n");
const { shelf } = await act("shelve", {});
await assert.rejects(act("shelf-drop", { sha: shelf.sha }), { code: "ECONFIRM" });
await assert.rejects(act("shelf-drop", { sha: "deadbeef", confirm: true }), {
code: "ENOSHELF",
});
await assert.rejects(act("unshelve", { sha: "not a sha" }), { code: "EBADSHA" });
assert.equal((await listShelves(REPO)).length, 1);
});
it("addresses a shelf by sha even after another stash shifted its index", async () => {
write("a.txt", "first\n");
const first = (await act("shelve", { note: "one" })).shelf;
write("sub/s.txt", "second\n");
const second = (await act("shelve", { note: "two" })).shelf;
// A user's own stash pushed on top, from a terminal.
write("shared.txt", "user\n");
g("stash", "push", "-q", "-m", "my own stash");
const listed = await listShelves(REPO);
assert.deepEqual(
listed.map((s) => s.note),
["two", "one"]
);
assert.equal(listed[1].ref, "stash@{2}");
await act("unshelve", { sha: first.sha.slice(0, 10) });
assert.equal(read("a.txt"), "first\n");
assert.equal(read("sub/s.txt"), "s\n");
assert.deepEqual(
(await listShelves(REPO)).map((s) => s.sha),
[second.sha]
);
// The user's stash is untouched and never listed.
assert.match(g("stash", "list"), /my own stash/);
await assert.rejects(
act("shelf-drop", {
sha: g("rev-parse", "stash@{0}").trim(),
confirm: true,
}),
{ code: "ENOSHELF" }
);
});
it("asks for confirm before shelving or restoring under a working agent", async () => {
write("a.txt", "wip\n");
await assert.rejects(act("shelve", {}, { agentWorking: true }), { code: "EAGENTRUNNING" });
const { shelf } = await act("shelve", { confirm: true }, { agentWorking: true });
await assert.rejects(act("unshelve", { sha: shelf.sha }, { agentWorking: true }), {
code: "EAGENTRUNNING",
});
await act("unshelve", { sha: shelf.sha, confirm: true }, { agentWorking: true });
assert.equal(read("a.txt"), "wip\n");
assert.equal(exists("a.txt"), true);
});
});
+293
View File
@@ -0,0 +1,293 @@
/**
* @file Branch & tag tools against a REAL repo (server/lib/git-refs.js via
* git-ops.js's runGitAction): branch rename (current or not; protected,
* existing and bad names refused), branch-create from a tag, a sha or a remote
* branch (and switch only on a clean tree), set/unset upstream, the refs read
* (ahead/behind, merged, held by a worktree, remotes grouped, tags light and
* annotated), tag create/delete (confirm)/push to a local bare origin, and the
* guards: injection-shaped names, ESUBDIR from a subdirectory lane, EMERGING
* during a merge.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { runGitAction } = require("../lib/git-ops");
const { listRefs } = require("../lib/git-refs");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-refs-"));
const REPO = path.join(ROOT, "repo");
const REMOTE = path.join(ROOT, "remote.git");
const WT = path.join(ROOT, "wt");
const env = () => {
const e = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
delete e.GIT_DIR;
delete e.GIT_WORK_TREE;
delete e.GIT_INDEX_FILE;
return e;
};
const gIn = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", env: env() });
const g = (...args) => gIn(REPO, ...args);
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
const sha = (ref) => g("rev-parse", ref).trim();
const heads = () =>
g("for-each-ref", "--format=%(refname:short)", "refs/heads").split("\n").filter(Boolean);
const tags = () => g("tag", "--list").split("\n").filter(Boolean);
const current = () => g("branch", "--show-current").trim();
// main: c1 -> c2 (pushed to origin/main); feat from c1 with one commit; tag v1 on c1.
beforeEach(() => {
for (const d of [REPO, REMOTE, WT]) fs.rmSync(d, { recursive: true, force: true });
fs.mkdirSync(path.join(REPO, "sub"), { recursive: true });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@h");
g("config", "user.name", "t");
write("a.txt", "1\n");
write("sub/s.txt", "s\n");
g("add", "-A");
g("commit", "-qm", "c1");
g("tag", "v1");
write("a.txt", "2\n");
g("commit", "-qam", "c2");
g("switch", "-qc", "feat", "v1");
write("f.txt", "f\n");
g("add", "-A");
g("commit", "-qm", "feat-1");
g("switch", "-q", "main");
gIn(ROOT, "init", "-q", "--bare", "remote.git");
g("remote", "add", "origin", REMOTE);
g("push", "-q", "-u", "origin", "main");
g("push", "-q", "origin", "feat:refs/heads/remote-only");
g("fetch", "-q", "origin");
});
describe("branch-rename", () => {
it("renames a branch that is not current", async () => {
const r = await runGitAction(REPO, "branch-rename", { branch: "feat", name: "feature/x" });
assert.equal(r.branch, "feature/x");
assert.ok(heads().includes("feature/x"));
assert.ok(!heads().includes("feat"));
});
it("renames the current branch", async () => {
g("switch", "-q", "feat");
await runGitAction(REPO, "branch-rename", { branch: "feat", name: "renamed" });
assert.equal(current(), "renamed");
});
it("refuses a protected branch, an existing name and a bad name", async () => {
await assert.rejects(runGitAction(REPO, "branch-rename", { branch: "main", name: "trunk" }), {
code: "EPROTECTED",
});
await assert.rejects(runGitAction(REPO, "branch-rename", { branch: "feat", name: "main" }), {
code: "EBRANCHEXISTS",
});
for (const name of ["-x", "a b", "a..b", "refs/heads/../x", "x~1", ""]) {
await assert.rejects(runGitAction(REPO, "branch-rename", { branch: "feat", name }), {
code: "EBADBRANCH",
});
}
await assert.rejects(runGitAction(REPO, "branch-rename", { branch: "nope", name: "y" }), {
code: "ENOBRANCH",
});
assert.ok(heads().includes("feat"));
});
});
describe("branch-create from", () => {
it("creates from a tag, a sha and a remote branch, without switching", async () => {
await runGitAction(REPO, "branch-create", { branch: "from-tag", from: "v1" });
assert.equal(sha("from-tag"), sha("v1^{commit}"));
const short = sha("feat").slice(0, 8);
await runGitAction(REPO, "branch-create", { branch: "from-sha", from: short });
assert.equal(sha("from-sha"), sha("feat"));
await runGitAction(REPO, "branch-create", {
branch: "from-remote",
from: "origin/remote-only",
});
assert.equal(sha("from-remote"), sha("origin/remote-only"));
// A remote start point becomes its upstream.
assert.equal(g("config", "--get", "branch.from-remote.merge").trim(), "refs/heads/remote-only");
assert.equal(current(), "main");
});
it("switches only on a clean tree", async () => {
write("a.txt", "dirty\n");
await assert.rejects(
runGitAction(REPO, "branch-create", { branch: "sw", from: "v1", switch: true }),
{ code: "EDIRTY" }
);
assert.ok(!heads().includes("sw"));
g("checkout", "--", "a.txt");
const r = await runGitAction(REPO, "branch-create", { branch: "sw", from: "v1", switch: true });
assert.equal(r.switched, true);
assert.equal(current(), "sw");
});
it("refuses a start point that is not a looked-up ref or commit", async () => {
for (const from of ["-x", "main..feat", "HEAD~1", "refs/heads/../main", "a b", "deadbeef"]) {
await assert.rejects(runGitAction(REPO, "branch-create", { branch: "z", from }), {
code: "EBADREF",
});
}
await assert.rejects(runGitAction(REPO, "branch-create", { branch: "feat", from: "v1" }), {
code: "EBRANCHEXISTS",
});
assert.ok(!heads().includes("z"));
});
it("asks for the full refname when a branch and a tag share a name", async () => {
g("branch", "v1", "feat");
await assert.rejects(runGitAction(REPO, "branch-create", { branch: "z", from: "v1" }), {
code: "EAMBIGUOUS",
});
await runGitAction(REPO, "branch-create", { branch: "z", from: "refs/tags/v1" });
assert.equal(sha("z"), sha("v1^{commit}"));
});
it("keeps the plain branch-create untouched", async () => {
await runGitAction(REPO, "branch-create", { branch: "plain" });
assert.equal(sha("plain"), sha("HEAD"));
});
});
describe("branch-upstream", () => {
it("sets and unsets the upstream", async () => {
await runGitAction(REPO, "branch-upstream", { branch: "feat", upstream: "origin/remote-only" });
assert.equal(g("rev-parse", "--abbrev-ref", "feat@{upstream}").trim(), "origin/remote-only");
await runGitAction(REPO, "branch-upstream", { branch: "feat" });
assert.throws(() => g("rev-parse", "--abbrev-ref", "feat@{upstream}"));
await assert.rejects(runGitAction(REPO, "branch-upstream", { branch: "feat" }), {
code: "ENOUPSTREAM",
});
});
it("refuses an upstream that is not a remote branch", async () => {
for (const upstream of ["feat", "origin/nope", "-x", "origin/HEAD"]) {
await assert.rejects(runGitAction(REPO, "branch-upstream", { branch: "feat", upstream }), {
code: "ENOBRANCH",
});
}
});
});
describe("listRefs", () => {
it("reports ahead/behind, merged, worktrees, remotes and tags", async () => {
write("a.txt", "3\n");
g("commit", "-qam", "c3");
g("worktree", "add", "-q", WT, "feat");
g("tag", "-a", "-m", "release one", "v2", "HEAD~1");
const r = await listRefs(REPO, { protectedNames: new Set(["main"]) });
assert.equal(r.current, "main");
const main = r.local.find((b) => b.name === "main");
assert.equal(main.upstream, "origin/main");
assert.equal(main.ahead, 1);
assert.equal(main.behind, 0);
assert.equal(main.current, true);
assert.equal(main.protected, true);
assert.equal(main.subject, "c3");
const feat = r.local.find((b) => b.name === "feat");
assert.equal(feat.upstream, null);
assert.equal(feat.ahead, null);
assert.equal(feat.merged, false);
assert.equal(fs.realpathSync(feat.worktree), fs.realpathSync(WT));
const origin = r.remotes.find((x) => x.remote === "origin");
assert.deepEqual(origin.branches.map((b) => b.branch).sort(), ["main", "remote-only"]);
const v1 = r.tags.find((t) => t.name === "v1");
assert.equal(v1.annotated, false);
assert.equal(v1.sha, sha("v1"));
const v2 = r.tags.find((t) => t.name === "v2");
assert.equal(v2.annotated, true);
assert.equal(v2.message, "release one");
assert.equal(v2.tagger, "t");
assert.equal(v2.sha, sha("HEAD~1"));
});
it("reports behind after the remote moves", async () => {
g("push", "-q", "origin", "feat:main", "--force");
g("fetch", "-q", "origin");
const main = (await listRefs(REPO)).local.find((b) => b.name === "main");
assert.equal(main.ahead, 1);
assert.equal(main.behind, 1);
});
});
describe("tags", () => {
it("creates a lightweight tag on HEAD and an annotated one on a commit", async () => {
await runGitAction(REPO, "tag-create", { tag: "light" });
assert.equal(g("cat-file", "-t", "light").trim(), "commit");
assert.equal(sha("light"), sha("HEAD"));
await runGitAction(REPO, "tag-create", { tag: "ann", message: "notes\n\nbody", from: "feat" });
assert.equal(g("cat-file", "-t", "ann").trim(), "tag");
assert.equal(sha("ann^{commit}"), sha("feat"));
await assert.rejects(runGitAction(REPO, "tag-create", { tag: "light" }), {
code: "ETAGEXISTS",
});
for (const tag of ["-x", "a b", "a..b", "x:y", "", "--force"]) {
await assert.rejects(runGitAction(REPO, "tag-create", { tag }), { code: "EBADTAG" });
}
await assert.rejects(runGitAction(REPO, "tag-create", { tag: "t2", from: "HEAD~1" }), {
code: "EBADREF",
});
});
it("deletes a tag only with confirm", async () => {
await assert.rejects(runGitAction(REPO, "tag-delete", { tag: "v1" }), { code: "ECONFIRM" });
assert.ok(tags().includes("v1"));
await runGitAction(REPO, "tag-delete", { tag: "v1", confirm: true });
assert.ok(!tags().includes("v1"));
await assert.rejects(runGitAction(REPO, "tag-delete", { tag: "v1", confirm: true }), {
code: "ENOTAG",
});
});
it("pushes one tag to origin, never others", async () => {
g("tag", "other");
const r = await runGitAction(REPO, "tag-push", { tag: "v1" });
assert.equal(r.remote, "origin");
const remoteTags = gIn(REMOTE, "tag", "--list").split("\n").filter(Boolean);
assert.deepEqual(remoteTags, ["v1"]);
await assert.rejects(runGitAction(REPO, "tag-push", { tag: "nope" }), { code: "ENOTAG" });
});
it("refuses a tag push that would move the remote's tag", async () => {
await runGitAction(REPO, "tag-push", { tag: "v1" });
g("tag", "-d", "v1");
g("tag", "v1", "feat");
await assert.rejects(runGitAction(REPO, "tag-push", { tag: "v1" }));
assert.equal(gIn(REMOTE, "rev-parse", "v1").trim(), sha("v1^{commit}~1"));
});
});
describe("guards", () => {
it("refuses repo-wide writes from a subdirectory lane", async () => {
const sub = path.join(REPO, "sub");
await assert.rejects(runGitAction(sub, "branch-rename", { branch: "feat", name: "x" }), {
code: "ESUBDIR",
});
await assert.rejects(runGitAction(sub, "tag-create", { tag: "t" }), { code: "ESUBDIR" });
await assert.rejects(runGitAction(sub, "tag-push", { tag: "v1" }), { code: "ESUBDIR" });
});
it("refuses during a merge", async () => {
g("switch", "-qc", "side", "v1");
write("a.txt", "side\n");
g("commit", "-qam", "side");
g("switch", "-q", "main");
assert.throws(() => g("merge", "-q", "side"));
await assert.rejects(runGitAction(REPO, "tag-create", { tag: "t" }), { code: "EMERGING" });
await assert.rejects(runGitAction(REPO, "branch-rename", { branch: "feat", name: "y" }), {
code: "EMERGING",
});
await assert.rejects(
runGitAction(REPO, "branch-create", { branch: "q", from: "v1", switch: true }),
{ code: "EMERGING" }
);
});
});
+613
View File
@@ -0,0 +1,613 @@
/**
* @file History rewrites, remote writes and the better-conflicts reads
* (git-rewrite.js, git-conflicts.js) against REAL temp repos with a local bare
* repo as `origin`: the merge editor's stage reads + save + resolve, the
* reflog, rebase (clean, conflict → continue/skip/abort, EMERGING for other
* writes), interactive rebase (squash/reword/drop/reorder, EPUSHED, undo),
* reset (soft/mixed/keep/hard guards, undo), force-with-lease (ok, ESTALE),
* protected-branch refusals, remote branch delete and remotes add/rename/
* remove with the URL allowlist.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { runGitAction } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
const { readConflict, readReflog } = require("../lib/git-conflicts");
const { rewritePlan, listRemotes, redactUrl } = require("../lib/git-rewrite");
const { contentSha } = require("../lib/git-diff");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-rewrite-"));
const REPO = path.join(ROOT, "repo");
const BARE = path.join(ROOT, "origin.git");
const OTHER = path.join(ROOT, "other");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const env = () => {
const e = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) delete e[k];
return e;
};
const gIn = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", env: env() });
const g = (...args) => gIn(REPO, ...args);
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
const sha = (ref) => g("rev-parse", ref).trim();
const subjects = (range = "main") => g("log", "--format=%s", range).trim().split("\n");
const commit = (file, body, msg) => {
write(file, body);
g("add", "-A");
g("commit", "-qm", msg);
};
beforeEach(() => {
for (const d of [REPO, BARE, OTHER]) fs.rmSync(d, { recursive: true, force: true });
gIn(ROOT, "init", "-q", "--bare", "-b", "main", BARE);
fs.mkdirSync(REPO);
g("init", "-q", "-b", "main");
g("config", "user.email", "t@h");
g("config", "user.name", "t");
commit("a.txt", "base\n", "init");
g("remote", "add", "origin", BARE);
g("push", "-q", "-u", "origin", "main");
});
/** A `feat` branch and a `main` that both edit a.txt (one line). */
function divergent() {
g("switch", "-qc", "feat");
commit("a.txt", "theirs\n", "feat edit");
commit("f.txt", "f\n", "feat file");
g("switch", "-q", "main");
commit("a.txt", "ours\n", "main edit");
}
describe("merge editor reads and save", () => {
it("reads base/ours/theirs and saves a resolved result", async () => {
divergent();
const m = await runGitAction(REPO, "merge", { branch: "feat" });
assert.deepEqual(m.conflicts, ["a.txt"]);
const c = await readConflict(REPO, "a.txt");
assert.equal(c.base, "base\n");
assert.equal(c.ours, "ours\n");
assert.equal(c.theirs, "theirs\n");
assert.equal(c.operation, "merge");
assert.equal(c.usable, true);
assert.match(c.working, /<<<<<<< /);
assert.equal(c.sha, contentSha(fs.readFileSync(path.join(REPO, "a.txt"))));
assert.equal(c.incoming.subject, "feat file");
await assert.rejects(readConflict(REPO, "f.txt"), { code: "EBADPATH" });
await assert.rejects(readConflict(REPO, "../x"), { code: "EBADPATH" });
// Markers left: refused before anything is written.
await assert.rejects(
runGitAction(REPO, "conflict-save", { path: "a.txt", content: c.working, baseSha: c.sha }),
{ code: "EMARKERS" }
);
await assert.rejects(
runGitAction(REPO, "conflict-save", {
path: "a.txt",
content: "x\n",
baseSha: "0".repeat(40),
}),
{ code: "ESTALE" }
);
const r = await runGitAction(REPO, "conflict-save", {
path: "a.txt",
content: "ours\ntheirs\n",
baseSha: c.sha,
});
assert.equal(r.resolved, true);
assert.equal(read("a.txt"), "ours\ntheirs\n");
assert.equal((await gitFacts(REPO)).conflicts, 0);
// No longer conflicted: neither readable nor savable here.
await assert.rejects(readConflict(REPO, "a.txt"), { code: "EBADPATH" });
await runGitAction(REPO, "merge-commit", {});
});
it("saves progress without resolving when resolve is false", async () => {
divergent();
await runGitAction(REPO, "merge", { branch: "feat" });
const c = await readConflict(REPO, "a.txt");
await runGitAction(REPO, "conflict-save", {
path: "a.txt",
content: c.working,
baseSha: c.sha,
resolve: false,
});
assert.equal((await gitFacts(REPO)).conflicts, 1);
});
});
describe("reflog", () => {
it("pages HEAD's and a branch's reflog and refuses an unknown ref", async () => {
commit("b.txt", "1\n", "second");
commit("b.txt", "2\n", "third");
const page = await readReflog(REPO, { limit: 2 });
assert.equal(page.entries.length, 2);
assert.equal(page.more, true);
assert.equal(page.entries[0].action, "commit");
assert.equal(page.entries[0].message, "third");
assert.equal(page.entries[0].selector, "HEAD@{0}");
assert.ok(page.entries[0].time > 0);
const next = await readReflog(REPO, { skip: 2, limit: 2 });
assert.equal(next.entries[0].index, 2);
const br = await readReflog(REPO, { ref: "main" });
assert.equal(br.ref, "main");
assert.ok(br.entries.length >= 3);
await assert.rejects(readReflog(REPO, { ref: "nope" }), { code: "ENOBRANCH" });
await assert.rejects(readReflog(REPO, { ref: "-x" }), { code: "EBADBRANCH" });
});
});
describe("rebase", () => {
it("rebases cleanly and offers an undo", async () => {
g("switch", "-qc", "feat");
commit("f.txt", "f\n", "feat file");
g("switch", "-q", "main");
commit("m.txt", "m\n", "main file");
g("switch", "-q", "feat");
const plan = await rewritePlan(REPO, { kind: "rebase", onto: "main" });
assert.equal(plan.commits.length, 1);
assert.equal(plan.behind, 1);
const r = await runGitAction(REPO, "rebase", { onto: "main", head: plan.head });
assert.deepEqual(r.conflicts, []);
assert.equal(r.before, plan.head);
assert.deepEqual(subjects("HEAD"), ["feat file", "main file", "init"]);
const u = await runGitAction(REPO, "undo-rewrite", {
to: r.before,
head: r.after,
confirm: true,
});
assert.equal(u.after, plan.head);
});
it("stops on a conflict; other writes answer EMERGING; continue finishes it", async () => {
divergent();
g("switch", "-q", "feat");
const r = await runGitAction(REPO, "rebase", { onto: "main", head: sha("HEAD") });
assert.deepEqual(r.conflicts, ["a.txt"]);
const facts = await gitFacts(REPO);
assert.equal(facts.operation, "rebase");
assert.equal(facts.conflicts, 1);
for (const [action, body] of [
["commit", { message: "x" }],
["switch", { branch: "main" }],
["merge", { branch: "main" }],
["reset", { target: sha("HEAD"), mode: "soft", head: sha("HEAD"), confirm: true }],
["rebase", { onto: "main", head: sha("HEAD") }],
["push", {}],
// Shelves and the Branches & tags tools wait for the rebase too.
["shelve", { confirm: true }],
["unshelve", { sha: "abcd", confirm: true }],
["shelf-drop", { sha: "abcd", confirm: true }],
["tag-create", { tag: "v1" }],
["branch-rename", { branch: "feat", to: "feat2" }],
]) {
await assert.rejects(runGitAction(REPO, action, body), { code: "EMERGING" }, action);
}
await assert.rejects(runGitAction(REPO, "rebase-continue", {}), { code: "ECONFLICTS" });
const c = await readConflict(REPO, "a.txt");
assert.equal(c.operation, "rebase");
await runGitAction(REPO, "conflict-save", {
path: "a.txt",
content: "merged\n",
baseSha: c.sha,
});
const done = await runGitAction(REPO, "rebase-continue", {});
assert.equal(done.stopped, false);
assert.equal((await gitFacts(REPO)).merging, false);
assert.deepEqual(subjects("HEAD"), ["feat file", "feat edit", "main edit", "init"]);
assert.equal(read("a.txt"), "merged\n");
});
it("skip drops the stopped commit; abort needs confirm and restores", async () => {
divergent();
g("switch", "-q", "feat");
const before = sha("HEAD");
await runGitAction(REPO, "rebase", { onto: "main", head: before });
const s = await runGitAction(REPO, "rebase-skip", {});
assert.equal(s.stopped, false);
assert.deepEqual(subjects("HEAD"), ["feat file", "main edit", "init"]);
g("reset", "-q", "--hard", before);
await runGitAction(REPO, "rebase", { onto: "main", head: before });
await assert.rejects(runGitAction(REPO, "rebase-abort", {}), { code: "ECONFIRM" });
// The banner's Abort (merge-abort) aborts a rebase too.
await runGitAction(REPO, "merge-abort", { confirm: true });
assert.equal(sha("HEAD"), before);
await assert.rejects(runGitAction(REPO, "rebase-continue", {}), { code: "ENOREBASE" });
});
it("refuses pushed commits without force, a dirty tree, a stale head and a bad ref", async () => {
g("switch", "-qc", "feat");
commit("f.txt", "f\n", "feat file");
g("push", "-q", "-u", "origin", "feat");
g("switch", "-q", "main");
commit("m.txt", "m\n", "main file");
g("switch", "-q", "feat");
const head = sha("HEAD");
await assert.rejects(runGitAction(REPO, "rebase", { onto: "main", head }), {
code: "EPUSHED",
});
await assert.rejects(runGitAction(REPO, "rebase", { onto: "nope", head }), {
code: "ENOBRANCH",
});
await assert.rejects(runGitAction(REPO, "rebase", { onto: "main", head: "0".repeat(40) }), {
code: "ESTALE",
});
write("f.txt", "dirty\n");
await assert.rejects(runGitAction(REPO, "rebase", { onto: "main", head, force: true }), {
code: "EDIRTY",
});
g("checkout", "--", "f.txt");
const r = await runGitAction(REPO, "rebase", { onto: "main", head, force: true });
assert.equal(r.needsForcePush, true);
});
it("refuses a subdirectory lane", async () => {
fs.mkdirSync(path.join(REPO, "sub"));
commit("sub/s.txt", "s\n", "sub");
await assert.rejects(
runGitAction(path.join(REPO, "sub"), "rebase", { onto: "main", head: sha("HEAD") }),
{ code: "ESUBDIR" }
);
});
});
describe("interactive rebase", () => {
beforeEach(() => {
commit("1.txt", "1\n", "one");
commit("2.txt", "2\n", "two");
commit("3.txt", "3\n", "three");
commit("4.txt", "4\n", "four");
});
it("plans only the unpushed commits by default", async () => {
const plan = await rewritePlan(REPO, { kind: "interactive" });
assert.deepEqual(
plan.commits.map((c) => c.subject),
["one", "two", "three", "four"]
);
assert.equal(plan.pushed, 0);
assert.equal(plan.base, sha("origin/main"));
});
it("squashes, rewords, drops and reorders without an editor", async () => {
const plan = await rewritePlan(REPO, { kind: "interactive" });
const [one, two, three, four] = plan.commits;
const r = await runGitAction(REPO, "rebase-interactive", {
head: plan.head,
confirm: true,
todo: [
{
sha: four.sha,
action: "reword",
message: "four, first now\n\nwith a body; $(rm -rf x) 'q'",
},
{ sha: one.sha, action: "pick" },
{ sha: two.sha, action: "squash" },
{ sha: three.sha, action: "drop" },
],
});
assert.deepEqual(r.conflicts, []);
assert.equal(r.stopped, false);
assert.deepEqual(subjects("HEAD"), ["one", "four, first now", "init"]);
assert.equal(g("log", "-1", "--format=%B", "HEAD").trim(), "one\n\ntwo");
assert.match(g("log", "-1", "--format=%B", "HEAD~1"), /\$\(rm -rf x\) 'q'/);
assert.equal(fs.existsSync(path.join(REPO, "3.txt")), false);
assert.equal(fs.existsSync(path.join(REPO, "2.txt")), true);
// No scratch file is left in the git dir.
assert.deepEqual(
fs.readdirSync(path.join(REPO, ".git")).filter((n) => n.startsWith("agenthub-rebase-")),
[]
);
const u = await runGitAction(REPO, "undo-rewrite", {
to: r.before,
head: r.after,
confirm: true,
});
assert.equal(u.after, plan.head);
});
it("squash with a message and fixup keep exactly what was asked", async () => {
const plan = await rewritePlan(REPO, { kind: "interactive" });
const [one, two, three, four] = plan.commits;
await runGitAction(REPO, "rebase-interactive", {
head: plan.head,
confirm: true,
todo: [
{ sha: one.sha, action: "pick" },
{ sha: two.sha, action: "fixup" },
{ sha: three.sha, action: "squash", message: "one to three" },
{ sha: four.sha, action: "pick" },
],
});
assert.deepEqual(subjects("HEAD"), ["four", "one to three", "init"]);
});
it("refuses bad todos, no change, pushed commits and needs confirm", async () => {
const plan = await rewritePlan(REPO, { kind: "interactive" });
const shas = plan.commits.map((c) => c.sha);
const base = { head: plan.head, confirm: true };
const picks = shas.map((s) => ({ sha: s, action: "pick" }));
await assert.rejects(
runGitAction(REPO, "rebase-interactive", { ...base, confirm: false, todo: picks }),
{
code: "ECONFIRM",
}
);
await assert.rejects(runGitAction(REPO, "rebase-interactive", { ...base, todo: picks }), {
code: "ENOCHANGES",
});
await assert.rejects(
runGitAction(REPO, "rebase-interactive", { ...base, todo: picks.slice(1) }),
{ code: "EBADTODO" }
);
await assert.rejects(
runGitAction(REPO, "rebase-interactive", {
...base,
todo: [{ sha: shas[0], action: "squash" }, ...picks.slice(1)],
}),
{ code: "EBADTODO" }
);
await assert.rejects(
runGitAction(REPO, "rebase-interactive", {
...base,
todo: [{ sha: shas[0], action: "exec rm" }, ...picks.slice(1)],
}),
{ code: "EBADTODO" }
);
await assert.rejects(
runGitAction(REPO, "rebase-interactive", {
...base,
todo: [{ sha: shas[0], action: "reword" }, ...picks.slice(1)],
}),
{ code: "EBADMESSAGE" }
);
// Push two of them: a base before them is now EPUSHED without force.
g("push", "-q", "origin", `${shas[1]}:refs/heads/main`);
const wide = await rewritePlan(REPO, { kind: "interactive", base: sha("main~4") });
assert.equal(wide.pushed, 2);
const reversed = [...wide.commits].reverse().map((c) => ({ sha: c.sha, action: "pick" }));
await assert.rejects(
runGitAction(REPO, "rebase-interactive", { ...base, base: wide.base, todo: reversed }),
{ code: "EPUSHED" }
);
// The default base now starts after the pushed ones.
const narrow = await rewritePlan(REPO, { kind: "interactive" });
assert.deepEqual(
narrow.commits.map((c) => c.subject),
["three", "four"]
);
});
});
describe("reset", () => {
beforeEach(() => {
commit("1.txt", "1\n", "one");
commit("2.txt", "2\n", "two");
});
const reset = (mode, extra = {}) =>
runGitAction(REPO, "reset", {
target: sha("HEAD~2"),
mode,
head: sha("HEAD"),
confirm: true,
...extra,
});
it("soft keeps the changes staged, mixed unstaged; undo puts the branch back", async () => {
const plan = await rewritePlan(REPO, { kind: "reset", target: sha("HEAD~2") });
assert.equal(plan.dropped, 2);
assert.equal(plan.pushed, 0);
assert.equal(plan.related, true);
const r = await reset("soft");
assert.match(g("status", "--porcelain"), /^A {2}1\.txt/m);
const u = await runGitAction(REPO, "undo-rewrite", {
to: r.before,
head: r.after,
confirm: true,
});
assert.equal(u.after, r.before);
await reset("mixed");
assert.match(g("status", "--porcelain"), /^\?\? 1\.txt/m);
});
it("keep refuses to overwrite a local change; hard needs a clean tree and the branch name", async () => {
write("2.txt", "local\n");
await assert.rejects(
reset("keep"),
(e) => typeof e.code === "number" || e.code === 128 || !!e.git
);
assert.equal(read("2.txt"), "local\n");
await assert.rejects(reset("hard", { confirmBranch: "main" }), { code: "EDIRTY" });
g("checkout", "--", "2.txt");
await assert.rejects(reset("hard"), { code: "ECONFIRM" });
await assert.rejects(reset("hard", { confirmBranch: "mian" }), { code: "ECONFIRM" });
const r = await reset("hard", { confirmBranch: "main" });
assert.equal(r.mode, "hard");
assert.equal(fs.existsSync(path.join(REPO, "1.txt")), false);
});
it("refuses pushed commits without force, an unrelated target, a bad mode, no confirm", async () => {
g("push", "-q", "origin", "main");
await assert.rejects(reset("soft"), { code: "EPUSHED" });
const r = await reset("soft", { force: true });
assert.equal(r.needsForcePush, true);
g("reset", "-q", "--hard", r.before);
await assert.rejects(reset("bogus"), { code: "EBADMODE" });
await assert.rejects(reset("soft", { confirm: false }), { code: "ECONFIRM" });
g("switch", "-q", "--orphan", "lonely");
commit("z.txt", "z\n", "lonely");
await assert.rejects(
runGitAction(REPO, "reset", {
target: sha("main"),
mode: "soft",
head: sha("HEAD"),
confirm: true,
}),
{ code: "ENOTRELATED" }
);
});
});
describe("force push and remote branches", () => {
function pushedFeat() {
g("switch", "-qc", "feat");
commit("f.txt", "1\n", "f1");
g("push", "-q", "-u", "origin", "feat");
// Rewrite it locally so a plain push is rejected.
g("commit", "-q", "--amend", "-m", "f1 amended");
}
it("force-pushes with a lease on the sha the plan showed", async () => {
pushedFeat();
const plan = await rewritePlan(REPO, { kind: "push" });
assert.equal(plan.remote, "origin");
assert.equal(plan.remoteBranch, "feat");
assert.equal(plan.remoteSha, sha("origin/feat"));
assert.equal(plan.allowed, true);
await assert.rejects(runGitAction(REPO, "force-push", { expect: plan.remoteSha }), {
code: "ECONFIRM",
});
await runGitAction(REPO, "force-push", { expect: plan.remoteSha, confirm: true });
assert.equal(gIn(BARE, "rev-parse", "feat").trim(), sha("HEAD"));
});
it("answers ESTALE when the remote moved", async () => {
pushedFeat();
const expect = sha("origin/feat");
gIn(ROOT, "clone", "-q", "-b", "feat", BARE, OTHER);
gIn(
OTHER,
"-c",
"user.email=o@h",
"-c",
"user.name=o",
"commit",
"-q",
"--allow-empty",
"-m",
"other"
);
gIn(OTHER, "push", "-q", "origin", "feat");
g("fetch", "-q");
await assert.rejects(runGitAction(REPO, "force-push", { expect, confirm: true }), {
code: "ESTALE",
});
});
it("refuses a protected branch unless the server allows it", async () => {
commit("x.txt", "x\n", "x");
const plan = await rewritePlan(REPO, { kind: "push" });
assert.equal(plan.protected, true);
assert.equal(plan.allowed, false);
await assert.rejects(
runGitAction(REPO, "force-push", { expect: plan.remoteSha, confirm: true }),
{ code: "EPROTECTED" }
);
process.env.AGENTHUB_GIT_FORCE_PUSH_PROTECTED = "1";
try {
await runGitAction(REPO, "force-push", { expect: plan.remoteSha, confirm: true });
} finally {
delete process.env.AGENTHUB_GIT_FORCE_PUSH_PROTECTED;
}
assert.equal(gIn(BARE, "rev-parse", "main").trim(), sha("HEAD"));
});
it("deletes a remote branch but never the default or a protected one", async () => {
g("push", "-q", "origin", "main:refs/heads/topic", "main:refs/heads/develop");
g("remote", "set-head", "origin", "main");
g("fetch", "-q");
await assert.rejects(runGitAction(REPO, "remote-branch-delete", { branch: "origin/topic" }), {
code: "ECONFIRM",
});
await assert.rejects(
runGitAction(REPO, "remote-branch-delete", { branch: "origin/develop", confirm: true }),
{ code: "EPROTECTED" }
);
await assert.rejects(
runGitAction(REPO, "remote-branch-delete", { branch: "origin/nope", confirm: true }),
{ code: "ENOBRANCH" }
);
await assert.rejects(
runGitAction(REPO, "remote-branch-delete", { branch: "--all", confirm: true }),
{ code: "EBADBRANCH" }
);
// A non-protected default branch is refused too.
g("push", "-q", "origin", "main:refs/heads/trunk");
gIn(BARE, "symbolic-ref", "HEAD", "refs/heads/trunk");
g("remote", "set-head", "origin", "trunk");
g("fetch", "-q");
await assert.rejects(
runGitAction(REPO, "remote-branch-delete", { branch: "origin/trunk", confirm: true }),
{ code: "EDEFAULTBRANCH" }
);
await runGitAction(REPO, "remote-branch-delete", { branch: "origin/topic", confirm: true });
assert.throws(() => gIn(BARE, "rev-parse", "--verify", "-q", "topic"));
});
});
describe("remotes", () => {
it("adds, renames and removes with the URL allowlist", async () => {
for (const url of [
"file:///tmp/x",
"ext::sh -c touch% /tmp/pwn",
"-xfoo",
"/tmp/local",
"http://h/x",
"https://u:p@h/x",
]) {
await assert.rejects(runGitAction(REPO, "remote-add", { name: "up", url }), {
code: "EBADURL",
});
}
for (const name of ["-x", "a b", "", "x..y", "x.lock"]) {
await assert.rejects(
runGitAction(REPO, "remote-add", { name, url: "https://example.com/r.git" }),
{ code: "EBADREMOTE" }
);
}
await assert.rejects(
runGitAction(REPO, "remote-add", { name: "origin", url: "https://example.com/r.git" }),
{ code: "EREMOTEEXISTS" }
);
await runGitAction(REPO, "remote-add", { name: "up", url: "git@example.com:o/r.git" });
await runGitAction(REPO, "remote-rename", { name: "up", to: "upstream" });
await assert.rejects(runGitAction(REPO, "remote-rename", { name: "upstream", to: "origin" }), {
code: "EREMOTEEXISTS",
});
let list = await listRemotes(REPO);
assert.deepEqual(
list.map((r) => r.name),
["origin", "upstream"]
);
await assert.rejects(runGitAction(REPO, "remote-remove", { name: "upstream" }), {
code: "ECONFIRM",
});
await runGitAction(REPO, "remote-remove", { name: "upstream", confirm: true });
list = await listRemotes(REPO);
assert.deepEqual(
list.map((r) => r.name),
["origin"]
);
await assert.rejects(runGitAction(REPO, "remote-remove", { name: "gone", confirm: true }), {
code: "EBADREMOTE",
});
});
it("never lists a password", () => {
assert.equal(redactUrl("https://bob:tok@h.com/x.git"), "https://h.com/x.git");
assert.equal(redactUrl("https://tok@h.com/x.git"), "https://h.com/x.git");
assert.equal(redactUrl("ssh://git:pw@h.com/x.git"), "ssh://git@h.com/x.git");
assert.equal(redactUrl("git@h.com:x.git"), "git@h.com:x.git");
});
});
+97 -1
View File
@@ -1126,6 +1126,67 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
assert.equal(r.body.current, "feat/api-changes");
});
it("shelves and restores through the routes, asking first while the agent works", async () => {
const lanesLib = require("../lib/lanes");
// No pane holds the lane: its hook-mirrored `running` IS an agent working.
lanesLib.updateLane(laneId, { status: "running", run_id: null });
try {
const refused = await request("POST", `/api/lanes/${laneId}/git/shelve`, { note: "api" });
assert.equal(refused.status, 409);
assert.equal(refused.body.error.code, "EAGENTRUNNING");
const made = await request("POST", `/api/lanes/${laneId}/git/shelve`, {
note: "api",
confirm: true,
});
assert.equal(made.status, 200, JSON.stringify(made.body));
const list = await request("GET", `/api/lanes/${laneId}/git/shelves`);
assert.equal(list.status, 200);
assert.equal(list.body.shelves.length, 1);
assert.equal(list.body.shelves[0].note, "api");
assert.equal(list.body.shelves[0].sha, made.body.shelf.sha);
const back = await request("POST", `/api/lanes/${laneId}/git/unshelve`, {
sha: made.body.shelf.sha,
confirm: true,
});
assert.equal(back.status, 200, JSON.stringify(back.body));
assert.equal(back.body.kept, false);
} finally {
lanesLib.updateLane(laneId, { status: "idle" });
}
assert.equal(fs.readFileSync(path.join(dir, "fresh.txt"), "utf8"), "new file\n");
const gone = await request("POST", `/api/lanes/${laneId}/git/shelf-drop`, {
sha: "deadbeef",
confirm: true,
});
assert.equal(gone.status, 409);
assert.equal(gone.body.error.code, "ENOSHELF");
});
it("lists branches and tags for the Branches panel, and answers tag errors as 400", async () => {
const created = await request("POST", `/api/lanes/${laneId}/git/tag-create`, { tag: "v0" });
assert.equal(created.status, 200, JSON.stringify(created.body));
const r = await request("GET", `/api/lanes/${laneId}/git/refs`);
assert.equal(r.status, 200);
assert.equal(r.body.available, true);
assert.equal(r.body.current, "feat/api-changes");
assert.deepEqual(
r.body.local.map((b) => b.name),
["feat/api-changes"]
);
assert.deepEqual(
r.body.tags.map((t) => t.name),
["v0"]
);
const dup = await request("POST", `/api/lanes/${laneId}/git/tag-create`, { tag: "v0" });
assert.equal(dup.status, 400);
assert.equal(dup.body.error.code, "ETAGEXISTS");
const del = await request("POST", `/api/lanes/${laneId}/git/tag-delete`, { tag: "v0" });
assert.equal(del.status, 400);
assert.equal(del.body.error.code, "ECONFIRM");
await request("POST", `/api/lanes/${laneId}/git/tag-delete`, { tag: "v0", confirm: true });
assert.equal((await request("GET", "/api/lanes/999999/git/refs")).status, 404);
});
it("commits through the action route and leaves the tree clean", async () => {
const r = await request("POST", `/api/lanes/${laneId}/git/commit`, {
message: "feat: through the api",
@@ -1141,7 +1202,7 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
assert.equal(empty.status, 400);
assert.equal(empty.body.error.code, "ENOCHANGES");
const bogus = await request("POST", `/api/lanes/${laneId}/git/rebase`, {});
const bogus = await request("POST", `/api/lanes/${laneId}/git/frobnicate`, {});
assert.equal(bogus.status, 400);
assert.equal(bogus.body.error.code, "EBADACTION");
@@ -1151,6 +1212,41 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
assert.equal(suggest.body.error.code, "ENOSTAGED");
});
it("serves the reflog, remotes and rewrite plans, and maps rewrite errors", async () => {
const reflog = await request("GET", `/api/lanes/${laneId}/git/reflog?limit=1`);
assert.equal(reflog.status, 200);
assert.equal(reflog.body.entries.length, 1);
const remotes = await request("GET", `/api/lanes/${laneId}/git/remotes`);
assert.deepEqual(remotes.body.remotes, []);
const plan = await request(
"GET",
`/api/lanes/${laneId}/git/rewrite-plan?kind=reset&target=${reflog.body.entries[0].sha}`
);
assert.equal(plan.status, 200);
assert.equal(plan.body.head.length, 40);
const badKind = await request("GET", `/api/lanes/${laneId}/git/rewrite-plan?kind=nope`);
assert.equal(badKind.status, 400);
assert.equal(badKind.body.error.code, "EBADMODE");
const conflict = await request("GET", `/api/lanes/${laneId}/git/conflict?path=a.txt`);
assert.equal(conflict.status, 400);
assert.equal(conflict.body.error.code, "EBADPATH");
const noRebase = await request("POST", `/api/lanes/${laneId}/git/rebase-continue`, {});
assert.equal(noRebase.status, 409);
assert.equal(noRebase.body.error.code, "ENOREBASE");
const badTodo = await request("POST", `/api/lanes/${laneId}/git/rebase-interactive`, {
confirm: true,
head: plan.body.head,
todo: [],
});
assert.equal(badTodo.status, 400, JSON.stringify(badTodo.body));
const badRemote = await request("POST", `/api/lanes/${laneId}/git/remote-add`, {
name: "-x",
url: "https://example.com/r.git",
});
assert.equal(badRemote.status, 400);
assert.equal(badRemote.body.error.code, "EBADREMOTE");
});
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
const r = await request("POST", `/api/lanes/${laneId}/git/publish`, { url: "file:///tmp/x" });
assert.equal(r.status, 400);
+223
View File
@@ -0,0 +1,223 @@
/**
* @file Better conflicts and history reads for the Changes panel: the three
* git stages of ONE conflicted file for the 3-way merge editor (`readConflict`,
* base `:1`, current `:2`, incoming `:3`, plus the working file it saves over),
* the editor's save (`conflict-save`: the whole-file editor's own write path,
* lane-file-write.js, then `git add` exactly like `resolve done`, refused while
* a conflict marker is left), and the read-only reflog (`readReflog`).
*
* A path is never trusted as a path: the conflict read accepts only a file
* `git ls-files -u` lists as unmerged right now (cwd-relative, so a
* subdirectory lane cannot name a file outside itself), keeps the read path's
* guards (`lanePath`, ESECRET, a size cap) and reads each stage by its BLOB id,
* never by a revision expression from the request. A reflog's ref is `HEAD` or
* a local branch that exists, looked up — never passed through.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { git: rawGit, isValidBranchName, operationInProgress } = require("./worktree");
const {
contentSha,
isSecretOnDisk,
lanePath,
looksBinary,
resolveInLane,
MAX_FILE_VIEW_BYTES,
CONFLICT_CODES,
} = require("./git-diff");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
/** A line git writes to open or close a conflict block (git-ops.js's own). */
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
/** Most reflog entries one page answers with. */
const MAX_REFLOG_PAGE = 200;
function bad(code, message) {
return Object.assign(new Error(message), { code });
}
/** `ls-files -u` as path → {1?: blob, 2?: blob, 3?: blob}; cwd-relative paths. */
async function unmergedStages(dir) {
const out = (await git(dir, ["ls-files", "-u", "-z"])).stdout;
const map = new Map();
for (const rec of out.split("\0").filter(Boolean)) {
const tab = rec.indexOf("\t");
const [, blob, stage] = rec.slice(0, tab).split(" ");
const p = rec.slice(tab + 1);
if (!map.has(p)) map.set(p, {});
map.get(p)[stage] = blob;
}
return map;
}
/** One stage's text, or `{missing}` / `{binary}` / `{tooBig}`. */
async function blobText(dir, blob) {
if (!blob) return { missing: true, text: null };
const size = Number((await git(dir, ["cat-file", "-s", blob])).stdout.trim());
if (size > MAX_FILE_VIEW_BYTES) return { tooBig: true, text: null };
const buf = (await git(dir, ["cat-file", "blob", blob], { encoding: "buffer" })).stdout;
if (looksBinary(buf)) return { binary: true, text: null };
return { text: buf.toString("utf8") };
}
/** The head file of the open operation, for "Incoming: <sha> <subject>". */
const INCOMING_HEAD = {
merge: "MERGE_HEAD",
"cherry-pick": "CHERRY_PICK_HEAD",
revert: "REVERT_HEAD",
rebase: "REBASE_HEAD",
};
/**
* The merge editor's input for `requested`: base/ours/theirs text, the working
* file (with its markers) and its `sha` — the `baseSha` a save must name.
* Throws EBADPATH (not unmerged), ESECRET, or answers a side as null when git
* has none (added on one side, deleted on the other).
*/
async function readConflict(dir, requested) {
const file = lanePath(requested);
if (await isSecretOnDisk(dir, file)) {
throw bad("ESECRET", "that file holds secrets and is not readable here");
}
const stages = (await unmergedStages(dir)).get(file);
if (!stages) throw bad("EBADPATH", `not a conflicted path: ${file}`);
const [base, ours, theirs] = await Promise.all(
["1", "2", "3"].map((s) => blobText(dir, stages[s]))
);
let working = { text: null, sha: null, binary: false, tooBig: false, missing: true };
try {
const abs = await resolveInLane(dir, file);
const fs = require("node:fs");
const st = await fs.promises.stat(abs);
if (st.isFile()) {
if (st.size > MAX_FILE_VIEW_BYTES) working = { ...working, missing: false, tooBig: true };
else {
const buf = await require("./run-as").readFileSafeAsync(abs);
working = looksBinary(buf)
? { ...working, missing: false, binary: true }
: { text: buf.toString("utf8"), sha: contentSha(buf), missing: false };
}
}
} catch (err) {
if (err.code === "ESECRET" || err.code === "EFOREIGN") throw err;
/* gone from the tree: one side deleted it */
}
const operation = await operationInProgress(dir);
let incoming = null;
const head = operation && INCOMING_HEAD[operation];
if (head) {
const line = await git(dir, ["log", "-1", "--format=%H%x00%h%x00%s", head, "--"]).then(
(r) => r.stdout.trim(),
() => ""
);
if (line) {
const [sha, short, subject] = line.split("\0");
incoming = { sha, short, subject };
}
}
const branch = (
await git(dir, ["branch", "--show-current"]).catch(() => ({ stdout: "" }))
).stdout.trim();
// Editable only when every side that exists is text under the cap and the
// working file is there to save over.
const sides = [base, ours, theirs];
const usable =
working.text != null && !working.binary && !sides.some((s) => s.binary || s.tooBig);
return {
path: file,
operation,
branch: branch || null,
incoming,
base: base.text,
ours: ours.text,
theirs: theirs.text,
working: working.text,
sha: working.sha,
usable,
binary: working.binary || sides.some((s) => s.binary),
tooBig: working.tooBig || sides.some((s) => s.tooBig),
};
}
/**
* `conflict-save {path, content, baseSha, resolve?}`: the merge editor's
* result over the working file — through lane-file-write.js (lanePath,
* resolveInLane, ESECRET, existing text file only, `baseSha` → ESTALE) — then
* `git add`, the same step `resolve done` takes. With `resolve` (default) a
* result that still holds a marker is refused BEFORE anything is written
* (EMARKERS); `resolve: false` saves the half-done result and leaves the file
* in conflict.
*/
async function saveConflict(dir, body) {
const { resolvePaths } = require("./git-ops")._internal;
const [target] = await resolvePaths(dir, { path: body.path });
if (!CONFLICT_CODES.has(target.code)) {
throw bad("EBADPATH", `not a conflicted path: ${target.path}`);
}
const resolve = body.resolve !== false;
if (typeof body.content !== "string") throw bad("EBADCONTENT", "content must be a string");
if (resolve && MARKER_RE.test(body.content)) {
throw bad("EMARKERS", `${target.path} still has conflict markers`);
}
await require("./lane-file-write").writeLaneFile(dir, target.path, body.content, body.baseSha);
const out = resolve ? await git(dir, ["add", "--", target.path]) : { stdout: "", stderr: "" };
const output = [out.stdout, out.stderr].filter(Boolean).join("").trim();
return { action: "conflict-save", ok: true, output, resolved: resolve };
}
/**
* One page of a reflog, newest first: `ref` is `HEAD` (default) or a local
* branch that exists. Each entry: `{index, selector, sha, short, action,
* message, time, subject}` — `action`/`message` split git's own reflog
* subject ("commit: fix"), `subject` is the commit's. `more` says another page
* exists. A ref with no reflog (never moved here) is an empty list.
*/
async function readReflog(dir, { ref, skip, limit } = {}) {
let name = "HEAD";
if (typeof ref === "string" && ref && ref !== "HEAD") {
if (!(await isValidBranchName(dir, ref))) throw bad("EBADBRANCH", "not a branch name");
const exists = await git(dir, ["show-ref", "--verify", "-q", `refs/heads/${ref}`]).then(
() => true,
() => false
);
if (!exists) throw bad("ENOBRANCH", `no local branch ${ref}`);
name = `refs/heads/${ref}`;
}
const from = Math.max(0, Number.parseInt(skip, 10) || 0);
const n = Math.min(MAX_REFLOG_PAGE, Math.max(1, Number.parseInt(limit, 10) || 50));
const out = await git(dir, [
"log",
"-g",
"--date=unix",
"--format=%H%x00%h%x00%gd%x00%gs%x00%s",
`--skip=${from}`,
"-n",
String(n + 1),
name,
"--",
]).then(
(r) => r.stdout,
() => ""
);
const rows = out.split("\n").filter(Boolean);
const label = name === "HEAD" ? "HEAD" : ref;
const entries = rows.slice(0, n).map((line, i) => {
const [sha, short, sel, gs, subject] = line.split("\0");
const time = Number((sel.match(/@\{(\d+)\}$/) || [])[1]) || null;
const colon = gs.indexOf(": ");
return {
index: from + i,
selector: `${label}@{${from + i}}`,
sha,
short,
action: colon > 0 ? gs.slice(0, colon) : gs,
message: colon > 0 ? gs.slice(colon + 2) : "",
time,
subject,
};
});
return { ref: label, entries, more: rows.length > n };
}
module.exports = { readConflict, saveConflict, readReflog, unmergedStages, MARKER_RE };
+835
View File
@@ -0,0 +1,835 @@
/**
* @file The Changes panel's history READS (issue: history & search, like the
* VS Code / GitLens / JetBrains git log): the commit graph page by page with
* its ref badges and search, one commit's details and its patch, a file's
* history across renames (`--follow`), blame at any revision, a read-only
* tree at any revision, a comparison of two refs, the plan a multi-commit
* cherry-pick/revert would run, and the state of one that is paused.
*
* Same rules as git-diff.js, whose guards these reuse: argv only through
* worktree.js's `git()` (literal pathspecs), every caller path through
* `lanePath`, every revision through `SAFE_REV` and then resolved to a full
* commit sha before git sees it as a revision, a secret file's CONTENT never
* returned at any revision (`isSecret`/`isSecretOnDisk`), the lane's cwd the
* boundary (`--relative`, and a tree listing that is cwd-limited by git
* itself), and every output capped. Heavy walks carry a timeout so a huge
* repo answers with an error, not a request held open.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const worktree = require("./worktree");
const {
lanePath,
isSecretOnDisk,
parseUnifiedDiff,
elideLongLines,
parseNumstat,
MAX_FILE_VIEW_BYTES,
} = require("./git-diff");
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
/** A walk of the history (log, blame, contains) may take this long. */
const READ_TIMEOUT_MS = 20_000;
/** Commits one history page may hold, and the default page. */
const MAX_PAGE = 500;
const DEFAULT_PAGE = 100;
/** Furthest a page may start: past this, narrow the search instead. */
const MAX_SKIP = 1_000_000;
/** Longest search text / author accepted. */
const MAX_QUERY = 200;
/** Commits each side of a comparison lists. */
const MAX_COMPARE_COMMITS = 300;
/** Files a comparison lists. */
const MAX_COMPARE_FILES = 1000;
/** Bytes of one file's patch in a comparison. */
const MAX_PATCH_BYTES = 200_000;
/** Bytes of a commit's patch (Copy patch). */
const MAX_FORMAT_PATCH = 2 * 1024 * 1024;
/** Commits one cherry-pick / revert may name. */
const MAX_PICK = 50;
/** Entries one tree listing returns. */
const MAX_TREE = 5000;
/** Branches / tags "contained in" lists, each. */
const MAX_CONTAINS = 50;
/** A revision the request may name: a sha or a ref name that cannot read as
* an option or a range (git-diff.js's own rule). */
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}(?:[~^][0-9]{0,4}){0,8}$/;
/** A commit id: abbreviated or full (64 = a SHA-256 repository). */
const SHA_RE = /^[0-9a-f]{4,64}$/i;
function bad(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/** A query value as a trimmed string, capped, or "" — EBADQUERY past the cap. */
function text(value, name) {
if (value == null) return "";
if (typeof value !== "string") throw bad("EBADQUERY", `${name} must be text`);
const v = value.trim();
if (v.length > MAX_QUERY || v.includes("\0")) throw bad("EBADQUERY", `${name} is too long`);
return v;
}
function int(value, def, min, max) {
const n = Math.floor(Number(value));
if (!Number.isFinite(n)) return def;
return Math.min(Math.max(n, min), max);
}
/**
* The full sha of the commit `rev` names, or EBADREV. `rev` passes SAFE_REV
* first, so git only ever sees `<safe name>^{commit}`, never an option or a
* range, and every later call uses the sha it answered.
*/
async function resolveCommit(dir, rev) {
if (typeof rev !== "string" || !SAFE_REV.test(rev) || rev.includes("..")) {
throw bad("EBADREV", "not a valid revision");
}
const sha = await git(dir, ["rev-parse", "-q", "--verify", `${rev}^{commit}`]).then(
(r) => r.stdout.trim(),
() => ""
);
if (!sha) throw bad("EBADREV", `no commit ${rev}`);
return sha;
}
/**
* Every ref that points at a commit, as the graph's badges: `{kind, name}`
* with kind `head` (a detached HEAD), `local`, `remote` or `tag`, and
* `current` on the branch HEAD is on. One `for-each-ref` per page — a
* thousand tags is still one call. `refs/remotes/<r>/HEAD` is an alias, not
* a branch, and is left out.
*/
async function refBadges(dir) {
const [refs, head, current] = await Promise.all([
git(dir, [
"for-each-ref",
"--format=%(objectname)%00%(*objectname)%00%(refname)",
"refs/heads",
"refs/remotes",
"refs/tags",
]).then(
(r) => r.stdout,
() => ""
),
git(dir, ["rev-parse", "-q", "--verify", "HEAD"]).then(
(r) => r.stdout.trim(),
() => ""
),
git(dir, ["symbolic-ref", "-q", "--short", "HEAD"]).then(
(r) => r.stdout.trim(),
() => ""
),
]);
const map = new Map();
const add = (sha, badge) => {
if (!map.has(sha)) map.set(sha, []);
map.get(sha).push(badge);
};
for (const line of refs.split("\n")) {
if (!line) continue;
const [obj, peeled, ref] = line.split("\0");
const sha = peeled || obj;
if (ref.startsWith("refs/heads/")) {
const name = ref.slice(11);
add(sha, { kind: "local", name, ...(name === current ? { current: true } : {}) });
} else if (ref.startsWith("refs/remotes/")) {
if (ref.endsWith("/HEAD")) continue;
add(sha, { kind: "remote", name: ref.slice(13) });
} else if (ref.startsWith("refs/tags/")) {
add(sha, { kind: "tag", name: ref.slice(10) });
}
}
if (head && !current) add(head, { kind: "head", name: "HEAD" });
return { map, head, current };
}
const LOG_FORMAT = ["%H", "%h", "%s", "%an", "%ae", "%aI", "%D", "%P"].join("%x1f");
function parseLog(stdout, badges) {
const commits = [];
for (const record of stdout.split("\x1e")) {
const line = record.replace(/^\n/, "");
if (!line.trim()) continue;
const [sha, short, subject, author, email, date, refs, parents] = line.split("\x1f");
commits.push({
sha,
short,
subject,
author,
email,
date,
refs: (refs || "")
.split(", ")
.map((r) => r.trim())
.filter(Boolean),
parents: (parents || "").trim().split(" ").filter(Boolean),
badges: badges?.get(sha) ?? [],
});
}
return commits;
}
/**
* One page of history, for the graph: newest first in `--date-order` (no
* parent before its children, which the graph's lanes need), each commit with
* its parents and ref badges.
*
* @param {object} q
* @param {number} [q.limit=100] Page size, ≤ 500.
* @param {number} [q.skip=0] Commits to skip (the page's start).
* @param {boolean|string} [q.all] Every branch, remote branch and tag (VS
* Code's "all branches"), the stash excluded.
* @param {string} [q.ref] Another branch's history (a short name, looked up).
* @param {string} [q.q] Message text, case-insensitive and literal; a hex
* string also finds the commit with that SHA prefix.
* @param {string} [q.author] Author name or e-mail, literal.
* @param {string} [q.path] Only commits touching this path (lane-relative).
* @returns {Promise<{commits, hasMore, filtered}>}
*/
async function listHistory(dir, q = {}) {
const limit = int(q.limit, DEFAULT_PAGE, 1, MAX_PAGE);
const skip = int(q.skip, 0, 0, MAX_SKIP);
const grep = text(q.q, "q");
const author = text(q.author, "author");
const file = q.path || q.file ? lanePath(String(q.path || q.file)) : null;
const all = q.all === true || q.all === "1" || q.all === "true";
const args = [
"log",
`--max-count=${limit + 1}`,
`--skip=${skip}`,
"--date-order",
`--format=${LOG_FORMAT}%x1e`,
];
if (grep || author) args.push("--regexp-ignore-case", "--fixed-strings");
if (grep) args.push(`--grep=${grep}`);
if (author) args.push(`--author=${author}`);
if (all) {
args.push("--exclude=refs/stash", "--all");
} else if (q.ref) {
const ref = text(q.ref, "ref");
const full = await worktree.branchRef(dir, ref);
if (!full) throw bad("ENOBRANCH", `no branch ${ref}`);
args.push(full);
}
// `--parents` rewrites each parent to the path's own previous commit, so a
// file's history still draws one joined-up line.
if (file) args.push("--parents", "--", file);
const [{ stdout }, badges] = await Promise.all([
git(dir, args, { timeout: READ_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 }),
refBadges(dir),
]);
let commits = parseLog(stdout, badges.map);
const hasMore = commits.length > limit;
if (hasMore) commits = commits.slice(0, limit);
// A SHA typed into the search: that commit first, whatever its message.
if (grep && SHA_RE.test(grep) && skip === 0) {
const sha = await resolveCommit(dir, grep).catch(() => null);
if (sha && !commits.some((c) => c.sha === sha)) {
const one = await git(dir, ["log", "-1", `--format=${LOG_FORMAT}%x1e`, sha]);
commits = [...parseLog(one.stdout, badges.map), ...commits];
}
}
return { commits, hasMore, filtered: !!(grep || author) };
}
/**
* One commit in full, for the details pane: the whole message, author and
* committer, parents, its own ref badges, and the branches and tags that
* contain it (each capped at MAX_CONTAINS). Its files come from
* `GET /git/changes?range=commit`, which already reads them.
*/
async function commitDetails(dir, rev) {
const sha = await resolveCommit(dir, rev);
const fmt = ["%H", "%h", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%P", "%B"].join("%x1f");
const [{ stdout }, badges, contains] = await Promise.all([
git(dir, ["show", "-s", `--format=${fmt}`, sha], { maxBuffer: 4 * 1024 * 1024 }),
refBadges(dir),
git(
dir,
[
"for-each-ref",
"--contains",
sha,
"--format=%(refname)",
"refs/heads",
"refs/remotes",
"refs/tags",
],
{ timeout: READ_TIMEOUT_MS }
).then(
(r) => r.stdout,
() => ""
),
]);
const [full, short, author, email, date, committer, committerEmail, committedAt, parents, body] =
stdout.split("\x1f");
const branches = [];
const tags = [];
for (const ref of contains.split("\n")) {
if (ref.startsWith("refs/heads/")) branches.push(ref.slice(11));
else if (ref.startsWith("refs/remotes/") && !ref.endsWith("/HEAD"))
branches.push(ref.slice(13));
else if (ref.startsWith("refs/tags/")) tags.push(ref.slice(10));
}
const message = (body || "").replace(/\n+$/, "");
return {
sha: full,
short,
subject: message.split("\n")[0],
message: message.length > 64 * 1024 ? message.slice(0, 64 * 1024) : message,
author,
email,
date,
committer,
committerEmail,
committedAt,
parents: (parents || "").trim().split(" ").filter(Boolean),
badges: badges.map.get(full) ?? [],
isHead: full === badges.head,
branches: branches.slice(0, MAX_CONTAINS),
tags: tags.slice(0, MAX_CONTAINS),
containsTruncated: branches.length > MAX_CONTAINS || tags.length > MAX_CONTAINS,
};
}
/**
* A commit as an e-mail patch (`git format-patch -1 --stdout`), for Copy
* patch: cwd-relative, and only the lane's own files — a secret file's diff
* is left out and named in `omitted`, never sent. A merge commit has no one
* patch (EMERGECOMMIT).
*/
async function commitPatch(dir, rev) {
const sha = await resolveCommit(dir, rev);
const parents = (await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ");
if (parents.length > 2) throw bad("EMERGECOMMIT", "a merge commit has no single patch");
const files = (
await git(dir, [
"diff-tree",
"--no-commit-id",
"--root",
"-r",
"--name-only",
"--relative",
"-z",
sha,
])
).stdout
.split("\0")
.filter(Boolean);
const keep = [];
const omitted = [];
for (const f of files) ((await isSecretOnDisk(dir, f)) ? omitted : keep).push(f);
if (!keep.length) return { sha, patch: "", omitted, truncated: false };
let out;
let truncated = false;
try {
out = (
await git(dir, ["format-patch", "-1", "--stdout", "--relative", sha, "--", ...keep], {
maxBuffer: MAX_FORMAT_PATCH + 64 * 1024,
timeout: READ_TIMEOUT_MS,
})
).stdout;
} catch (err) {
if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err;
out = String(err.git.stdout);
}
if (out.length > MAX_FORMAT_PATCH) {
out = out.slice(0, MAX_FORMAT_PATCH);
truncated = true;
}
return { sha, patch: out, omitted, truncated };
}
/**
* One file's history across renames (`git log --follow`), newest first: each
* commit with the path the file had THERE and its status (A/M/R/D…), so a
* version's diff is read under its own name. Paths are lane-relative.
*/
async function fileHistory(dir, requested, q = {}) {
const file = lanePath(requested);
if (await isSecretOnDisk(dir, file)) {
throw bad("ESECRET", "that file holds secrets and is not readable here");
}
const limit = int(q.limit, DEFAULT_PAGE, 1, MAX_PAGE);
const skip = int(q.skip, 0, 0, MAX_SKIP);
const fmt = ["%H", "%h", "%s", "%an", "%aI", "%P"].join("%x1f");
const { stdout } = await git(
dir,
[
"log",
"--follow",
"--relative",
"-z",
"--name-status",
`--max-count=${limit + 1}`,
`--skip=${skip}`,
`--format=%x1e${fmt}`,
"--",
file,
],
{ timeout: READ_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 }
);
const commits = [];
for (const record of stdout.split("\x1e")) {
if (!record.trim()) continue;
const [head, ...rest] = record.split("\0");
const [sha, short, subject, author, date, parents] = head.split("\x1f");
const tokens = rest.map((t) => t.replace(/^\n/, "")).filter((t) => t !== "");
const status = tokens[0] || "M";
const letter = status[0];
const rename = letter === "R" || letter === "C";
commits.push({
sha,
short,
subject,
author,
date,
parents: (parents || "").trim().split(" ").filter(Boolean),
status: letter,
path: rename ? tokens[2] || file : tokens[1] || file,
...(rename ? { from: tokens[1] } : {}),
});
}
const hasMore = commits.length > limit;
return { path: file, commits: hasMore ? commits.slice(0, limit) : commits, hasMore };
}
/**
* Blame at a revision (`rev` = `work` for the file on disk, else a commit):
* each line's text and the commit that last changed it, plus that commit's
* author, date, summary and `previous` (where "blame before this change"
* goes). Guards of a whole-file read: lexical path, secret refused, 1 MB cap.
*/
async function blameAt(dir, requested, rev = "work") {
const file = lanePath(requested);
if (await isSecretOnDisk(dir, file)) {
throw bad("ESECRET", "that file holds secrets and is not readable here");
}
const args = ["blame", "--porcelain"];
let at = null;
if (rev && rev !== "work") {
at = await resolveCommit(dir, rev);
const size = await git(dir, ["cat-file", "-s", `${at}:./${file}`]).then(
(r) => Number(r.stdout.trim()),
() => -1
);
if (size < 0) throw bad("ENOFILE", "no such file at that revision");
if (size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file too large to annotate");
args.push(at);
} else {
const fs = require("node:fs");
const st = await fs.promises.stat(path.join(dir, file)).catch(() => null);
if (!st?.isFile()) throw bad("ENOFILE", "no such file");
if (st.size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file too large to annotate");
}
const out = (
await git(dir, [...args, "--", file], {
maxBuffer: 64 * 1024 * 1024,
timeout: READ_TIMEOUT_MS,
})
).stdout;
const commits = {};
const lines = [];
let sha = null;
for (const line of out.split("\n")) {
const head = /^([0-9a-f]{40,64}) \d+ \d+/.exec(line);
if (head) {
sha = head[1];
if (!/^0+$/.test(sha)) commits[sha] ??= { short: sha.slice(0, 7) };
continue;
}
if (line.startsWith("\t")) {
lines.push({ sha: /^0+$/.test(sha) ? null : sha, text: line.slice(1) });
continue;
}
const c = commits[sha];
if (!c) continue;
const sp = line.indexOf(" ");
const key = sp < 0 ? line : line.slice(0, sp);
const val = sp < 0 ? "" : line.slice(sp + 1);
if (key === "author") c.author = val;
else if (key === "author-time") c.date = new Date(Number(val) * 1000).toISOString();
else if (key === "summary") c.summary = val;
else if (key === "previous") c.previous = val.split(" ")[0];
}
return { path: file, rev: at ?? "work", commits, lines };
}
/**
* One folder of the tree at a commit (`git ls-tree`), for Browse files at a
* revision: cwd-limited by git itself, a secret file left out of the list
* (its content is refused by `/git/file` anyway), folders first. A file is
* then read with `GET /git/file?rev=<sha>` under that read's own guards.
*/
async function treeAt(dir, rev, sub = "") {
const sha = await resolveCommit(dir, rev);
const folder = sub ? lanePath(sub) : "";
const args = ["ls-tree", "-z", "-l", sha];
if (folder) args.push("--", `${folder}/`);
const { stdout } = await git(dir, args, { maxBuffer: 16 * 1024 * 1024 });
const entries = [];
for (const row of stdout.split("\0")) {
if (!row) continue;
const tab = row.indexOf("\t");
const [, type, , size] = row.slice(0, tab).trim().split(/\s+/);
const p = row.slice(tab + 1);
if (await isSecretOnDisk(dir, p)) continue;
entries.push({
path: p,
name: p.slice(p.lastIndexOf("/") + 1),
dir: type === "tree",
submodule: type === "commit",
size: type === "blob" ? Number(size) : null,
});
}
entries.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name));
const truncated = entries.length > MAX_TREE;
return {
rev: sha,
path: folder,
entries: truncated ? entries.slice(0, MAX_TREE) : entries,
truncated,
};
}
/** `--name-status -z` + numstat into the Changes panel's file rows. */
function filesFrom(nameStatus, stats) {
const files = [];
const tokens = nameStatus.split("\0");
for (let i = 0; i < tokens.length; i++) {
const status = tokens[i].replace(/^\n/, "");
if (!/^[A-Z]/.test(status)) continue;
const letter = status[0];
const rename = letter === "R" || letter === "C";
const from = rename ? tokens[++i] : undefined;
const file = tokens[++i];
if (!file) continue;
const stat = stats.get(file);
files.push({
path: file,
...(from ? { from } : {}),
status: `${letter} `,
untracked: false,
staged: false,
unstaged: false,
conflicted: false,
added: stat?.added ?? 0,
deleted: stat?.deleted ?? 0,
binary: !!stat?.binary,
});
}
return files;
}
/**
* Compare two refs (branches, tags or commits): the commits only in `a` and
* only in `b` (`--left-right a...b`), and the files that differ — `three`
* (default) is `a...b`, what `b` adds since the two diverged; `two` is `a b`,
* the trees as they stand. `path` returns that one file with its hunks.
*/
async function compareRefs(dir, { a, b, mode = "three", path: file = null, from = null } = {}) {
const left = await resolveCommit(dir, a);
const right = await resolveCommit(dir, b);
const range = mode === "two" ? [left, right] : [`${left}...${right}`];
if (file) {
const p = lanePath(file);
// A rename's old name too, so its diff reads as a change, not an add.
const old = from ? lanePath(from) : null;
if ((await isSecretOnDisk(dir, p)) || (old && (await isSecretOnDisk(dir, old)))) {
return { a: left, b: right, mode, file: { path: p, secret: true, hunks: [] } };
}
let patch = "";
try {
patch = (
await git(
dir,
[
"diff",
"-U3",
"-M",
"--no-color",
"--no-ext-diff",
"--relative",
...range,
"--",
...(old && old !== p ? [old, p] : [p]),
],
{ maxBuffer: 16 * 1024 * 1024, timeout: READ_TIMEOUT_MS }
)
).stdout;
} catch (err) {
if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err;
patch = String(err.git.stdout);
}
patch = elideLongLines(patch);
let truncated = false;
if (patch.length > MAX_PATCH_BYTES) {
const nl = patch.lastIndexOf("\n", MAX_PATCH_BYTES);
patch = patch.slice(0, nl > MAX_PATCH_BYTES / 2 ? nl + 1 : MAX_PATCH_BYTES);
truncated = true;
}
return {
a: left,
b: right,
mode,
file: {
path: p,
status: "M ",
untracked: false,
staged: false,
unstaged: false,
conflicted: false,
added: 0,
deleted: 0,
binary: /^Binary files /m.test(patch),
hunks: parseUnifiedDiff(patch),
...(truncated ? { truncated } : {}),
},
};
}
const fmt = ["%m", "%H", "%h", "%s", "%an", "%aI", "%P"].join("%x1f");
const opts = { timeout: READ_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 };
const [log, ns, num] = await Promise.all([
git(
dir,
[
"log",
"--left-right",
`--max-count=${MAX_COMPARE_COMMITS * 4}`,
`--format=${fmt}%x1e`,
`${left}...${right}`,
],
opts
),
git(dir, ["diff", "--name-status", "-z", "-M", "--relative", ...range], opts),
git(dir, ["diff", "--numstat", "-z", "-M", "--relative", ...range], opts),
]);
const onlyA = [];
const onlyB = [];
for (const record of log.stdout.split("\x1e")) {
const line = record.replace(/^\n/, "");
if (!line.trim()) continue;
const [side, sha, short, subject, author, date, parents] = line.split("\x1f");
const c = {
sha,
short,
subject,
author,
date,
parents: (parents || "").split(" ").filter(Boolean),
};
(side === "<" ? onlyA : onlyB).push(c);
}
let files = filesFrom(ns.stdout, parseNumstat(num.stdout));
const filesTruncated = files.length > MAX_COMPARE_FILES;
if (filesTruncated) files = files.slice(0, MAX_COMPARE_FILES);
return {
a: left,
b: right,
mode: mode === "two" ? "two" : "three",
onlyA: onlyA.slice(0, MAX_COMPARE_COMMITS),
onlyB: onlyB.slice(0, MAX_COMPARE_COMMITS),
commitsTruncated: onlyA.length > MAX_COMPARE_COMMITS || onlyB.length > MAX_COMPARE_COMMITS,
files,
filesTruncated,
};
}
/**
* The commits of a cherry-pick or revert request as git will apply them:
* each resolved to a full sha (EBADSHA), none a merge commit (EMERGECOMMIT),
* no duplicates, at most MAX_PICK; a cherry-pick oldest → newest, a revert
* newest → oldest (undoing the later change first is what keeps the earlier
* one's context intact). Order is ancestry first, then date (`--topo-order`).
*/
async function pickPlan(dir, op, shas) {
if (op !== "cherry-pick" && op !== "revert")
throw bad("EBADACTION", "op must be cherry-pick or revert");
if (!Array.isArray(shas) || !shas.length || shas.length > MAX_PICK) {
throw bad("EBADSHA", `name 1 to ${MAX_PICK} commits`);
}
const full = [];
for (const s of shas) {
if (typeof s !== "string" || !SHA_RE.test(s)) throw bad("EBADSHA", "sha must be a commit id");
const sha = await git(dir, ["rev-parse", "-q", "--verify", `${s}^{commit}`]).then(
(r) => r.stdout.trim(),
() => ""
);
if (!sha) throw bad("EBADSHA", `no commit ${s}`);
if (!full.includes(sha)) full.push(sha);
}
// Ancestry first (a parent is never applied after its child, whatever the
// clocks said), then date: `--topo-order` over the commits between them
// and their common ancestor — a bounded walk, not the whole history.
let ordered = full;
if (full.length > 1) {
const base = await git(dir, ["merge-base", "--octopus", ...full]).then(
(r) => r.stdout.trim(),
() => ""
);
const walk = await git(
dir,
["rev-list", "--topo-order", ...full, ...(base ? [`^${base}`] : [])],
{ timeout: READ_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 }
);
const want = new Set(full);
ordered = walk.stdout.split("\n").filter((s) => want.has(s));
// The common ancestor is excluded from the walk; it is the oldest.
if (want.has(base)) ordered.push(base);
}
const fmt = ["%H", "%h", "%s", "%an", "%aI", "%P"].join("%x1f");
const { stdout } = await git(dir, [
"log",
"--no-walk=unsorted",
`--format=${fmt}%x1e`,
...ordered,
]);
const commits = [];
for (const record of stdout.split("\x1e")) {
const line = record.replace(/^\n/, "");
if (!line.trim()) continue;
const [sha, short, subject, author, date, parents] = line.split("\x1f");
const p = (parents || "").trim().split(" ").filter(Boolean);
if (p.length > 1)
throw bad("EMERGECOMMIT", `${short} is a merge commit; pick it in a terminal`);
commits.push({ sha, short, subject, author, date });
}
// git printed newest first; a cherry-pick replays history forwards.
if (op === "cherry-pick") commits.reverse();
return commits;
}
/** The plan plus the facts the confirmation shows: branch, clean, repo root. */
async function pickPreview(dir, op, shas) {
const commits = await pickPlan(dir, op, shas);
const [branch, prefix, status] = await Promise.all([
git(dir, ["branch", "--show-current"]).then(
(r) => r.stdout.trim(),
() => ""
),
git(dir, ["rev-parse", "--show-prefix"]).then(
(r) => r.stdout.trim(),
() => ""
),
worktree.statusCounts(dir),
]);
const operation = await worktree.operationInProgress(dir);
return {
op,
commits,
branch: branch || null,
clean: status.dirty + status.untracked === 0,
repoRoot: !prefix,
busy: !!operation || (await sequencerTodo(dir)).length > 0,
};
}
/** `.git/sequencer/todo`'s lines ("pick <sha> <subject>"), [] when none. */
async function sequencerTodo(dir) {
try {
const rel = (await git(dir, ["rev-parse", "--git-path", "sequencer/todo"])).stdout.trim();
const body = await require("./run-as").readFileSafeAsync(path.resolve(dir, rel), "utf8", {
max: 256 * 1024,
});
return body
.split("\n")
.map((l) => /^(pick|revert|p|r)\s+([0-9a-f]{4,64})\s?(.*)$/.exec(l.trim()))
.filter(Boolean)
.map((m) => ({ verb: m[1].startsWith("p") ? "pick" : "revert", sha: m[2], subject: m[3] }));
} catch {
return [];
}
}
/** The pre-operation HEAD git saved for a multi-commit pick/revert, or null. */
async function sequencerHead(dir) {
try {
const rel = (await git(dir, ["rev-parse", "--git-path", "sequencer/head"])).stdout.trim();
const v = (
await require("./run-as").readFileSafeAsync(path.resolve(dir, rel), "utf8", { max: 1024 })
).trim();
return /^[0-9a-f]{40,64}$/.test(v) ? v : null;
} catch {
return null;
}
}
/**
* A cherry-pick or revert that is open (one commit, or a sequence paused on a
* conflict): which, the commit it stopped at, the ones still to come, how many
* are already applied, the HEAD it started from and the conflicted paths —
* what the panel's "paused at commit X (2 of 5)" banner draws.
*/
async function sequenceState(dir) {
const operation = await worktree.operationInProgress(dir);
const todo = await sequencerTodo(dir);
// A merge or a rebase has its own banner: never reported as a paused pick.
if (
(operation && operation !== "cherry-pick" && operation !== "revert") ||
(!operation && !todo.length)
) {
return { active: false };
}
const op = operation || (todo[0].verb === "revert" ? "revert" : "cherry-pick");
const headName = op === "revert" ? "REVERT_HEAD" : "CHERRY_PICK_HEAD";
const current = await git(dir, ["rev-parse", "-q", "--verify", headName]).then(
(r) => r.stdout.trim(),
() => ""
);
const startedFrom = await sequencerHead(dir);
const done = startedFrom
? await git(dir, ["rev-list", "--count", `${startedFrom}..HEAD`]).then(
(r) => Number(r.stdout.trim()) || 0,
() => 0
)
: 0;
const describe = async (sha) => {
const r = await git(dir, ["log", "-1", "--format=%H%x1f%h%x1f%s", sha]).catch(() => null);
if (!r) return null;
const [full, short, subject] = r.stdout.trim().split("\x1f");
return { sha: full, short, subject };
};
const unmerged = (
await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]).catch(() => ({ stdout: "" }))
).stdout
.split("\0")
.filter(Boolean);
// The todo's first line is the commit it stopped at; the rest are to come.
const rest = current && todo.length && current.startsWith(todo[0].sha) ? todo.slice(1) : todo;
return {
active: true,
operation: op,
current: current ? await describe(current) : null,
remaining: rest
.slice(0, MAX_PICK * 2)
.map((t) => ({ sha: t.sha, short: t.sha.slice(0, 7), subject: t.subject })),
done,
startedFrom,
conflicts: unmerged,
};
}
module.exports = {
listHistory,
commitDetails,
commitPatch,
fileHistory,
blameAt,
treeAt,
compareRefs,
pickPlan,
pickPreview,
sequenceState,
sequencerTodo,
sequencerHead,
resolveCommit,
MAX_PICK,
MAX_PAGE,
};
+463 -42
View File
@@ -5,15 +5,36 @@
* .gitignore (`ignore`), switch branch, create or
* delete a branch, publish a repo that has no remote yet, and merge a branch
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
* hand-edited, merge-commit, merge-abort). There is no rebase, no hard reset
* and no stash, because a dashboard button is the wrong place to start an
* operation whose recovery needs a terminal (a hard `reset` stays where it
* was: behind the three-check destroy guard in worktree.js). A merge is the
* hand-edited, merge-commit, merge-abort). History REWRITES — rebase,
* interactive rebase, reset, force-with-lease push, remote branch delete and
* remote add/rename/remove — live in git-rewrite.js (dispatched from
* `runGitAction`), each allowed only because its recovery is a button too;
* the 3-way merge editor's save is git-conflicts.js. A stopped rebase lets
* through only ALLOWED_WHILE_REBASING.
*
* Shelves (`shelve`, `unshelve`, `shelf-drop`, and `switch` with
* `changes: "carry" | "shelve"`) are git stash under the hood, allowed
* because their recovery is a button too: every shelf is listed
* (`listShelves`, only entries whose message carries SHELF_PREFIX — any
* other stash is the user's and never shown or touched) with Restore and
* Delete, addressed by its commit sha (never `stash@{n}`, which a terminal
* stash would shift), and nothing drops one except a clean restore or a
* Delete with `confirm: true`. They are repo-wide, so repo root only
* (ESUBDIR), never during a merge/cherry-pick/revert/rebase (EMERGING), and
* a restore needs a clean tree (EDIRTY). A conflicted restore keeps its
* shelf and leaves the unmerged files to `resolve`. A write that moves
* files under an agent the route reports as working asks for
* `confirm: true` first (EAGENTRUNNING). A merge is the
* exception because its recovery is ONE button here too — `merge-abort` puts
* the branch back where it was — and `cherry-pick` and `revert` follow it:
* same clean-tree/repo-root guards, same conflict flow, and `merge-commit`/
* `merge-abort` finish or abort whichever of the three git has open.
*
* Several commits at once (`shas`), a paused pick's `pick-continue` /
* `pick-skip` / `pick-abort`, and `undo-pick` (a soft reset back over what
* a pick just made) live in git-pick.js, dispatched at the top of
* `runGitAction` under the same guards.
*
* `undo-commit` (`reset --soft HEAD~1`) and `amend` rewrite the last commit,
* so both refuse once any remote-tracking branch holds it (EPUSHED): an
* unpushed commit is the only one whose rewrite costs nobody else anything.
@@ -30,13 +51,15 @@
* - a caller-supplied path is never trusted as a path: it must be one
* `git status` is reporting as changed right now (`resolvePaths`), so no
* `..`, no absolute path and no untouched file is reachable.
* - `pull` is `--ff-only` and `switch` demands a clean tree, so neither can
* invent a merge commit or a conflict underneath a running agent. A merge
* - `pull` is `--ff-only` and `switch` demands a clean tree unless the
* request names what happens to the changes (carry them, or shelve them),
* so neither can invent a merge commit or a conflict underneath a running
* agent. A merge
* happens only when asked for by name (`merge`), on a clean tree, from a
* lane that IS the repo root (a merge is repo-wide), one at a time.
*
* `discard`, `discard-hunk` and `merge-abort` throw away work git has no copy
* of, so each demands an explicit `confirm: true` in the body. Do not add
* `discard`, `discard-hunk`, `merge-abort` and `shelf-drop` throw away work
* git has no other copy of, so each demands an explicit `confirm: true` in the body. Do not add
* another destructive action without the same gate.
*
* `publish` is the ONE action whose destination comes from the request (a
@@ -63,6 +86,7 @@ const {
} = require("./worktree");
const { hunkPatch, selectionPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
const gitCredentials = require("./git-credentials");
const gitRefs = require("./git-refs");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
@@ -94,8 +118,19 @@ const ACTIONS = new Set([
"undo-commit",
"amend",
"ignore",
"shelve",
"unshelve",
"shelf-drop",
// git-pick.js: a paused cherry-pick/revert's banner, and taking a pick back.
"pick-continue",
"pick-skip",
"pick-abort",
"undo-pick",
]);
// Branch & tag tools (rename, upstream, tag create/delete/push): git-refs.js.
for (const a of gitRefs.REF_ACTIONS) ACTIONS.add(a);
/** Actions that would tangle with a merge, cherry-pick or revert in progress. */
const BLOCKED_WHILE_MERGING = new Set([
"pull",
@@ -107,6 +142,8 @@ const BLOCKED_WHILE_MERGING = new Set([
"revert",
"undo-commit",
"amend",
"shelve",
"unshelve",
]);
/** A commit as the request may name it: an abbreviated or full hex sha
@@ -114,17 +151,25 @@ const BLOCKED_WHILE_MERGING = new Set([
const SHA_RE = /^[0-9a-f]{4,64}$/i;
/**
* Whether a rebase (started in a terminal) is stopped in this repo. It leaves
* no *_HEAD the dashboard finishes, so it gets no banner and no button — but
* nothing here may move HEAD or commit underneath it either.
* The only actions a stopped rebase (git-rewrite.js's, or a terminal's) lets
* through: the ones that resolve its conflicts and finish or abort it
* (`merge-commit`/`merge-abort` = the banner's Continue/Abort). Every other
* write answers EMERGING — nothing may move HEAD or commit underneath it.
*/
async function rebaseInProgress(dir) {
for (const name of ["rebase-merge", "rebase-apply"]) {
const rel = (await git(dir, ["rev-parse", "--git-path", name])).stdout.trim();
if (fs.existsSync(path.resolve(dir, rel))) return true;
}
return false;
}
const ALLOWED_WHILE_REBASING = new Set([
"fetch",
"stage",
"unstage",
"discard",
"ignore",
"resolve",
"conflict-save",
"merge-commit",
"merge-abort",
"rebase-continue",
"rebase-skip",
"rebase-abort",
]);
/** A line git writes to open or close a conflict block. */
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
@@ -324,20 +369,42 @@ async function networkGit(dir, args, body = {}) {
* @param {string} dir The lane's cwd.
* @param {string} action One of ACTIONS.
* @param {object} [body] Action input: `{message}` for commit,
* `{branch, create}` for switch. Ignored otherwise.
* `{branch, create, changes}` for switch, `{note, paths}` for shelve,
* `{sha}` for unshelve/shelf-drop, and so on.
* @param {{agentWorking?: boolean}} [opts] What the route knows about the
* lane: `agentWorking` makes every write that moves files under a
* working agent (shelve, unshelve, a switch carrying or shelving changes)
* demand `confirm: true` (EAGENTRUNNING).
* @returns {Promise<{action, ok, output, branch?}>} `output` is git's own
* stdout+stderr, shown verbatim — a push rejection or a credential failure
* says more in git's words than in ours.
*/
async function runGitAction(dir, action, body = {}) {
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
if (BLOCKED_WHILE_MERGING.has(action) || action === "merge-commit" || action === "merge-abort") {
// A rebase blocks even Complete/Abort: `commit` mid-rebase would make a
// commit the rebase never planned. It is finished where it was started.
if (await rebaseInProgress(dir)) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
const open = BLOCKED_WHILE_MERGING.has(action) && (await operationInProgress(dir));
async function runGitAction(dir, action, body = {}, opts = {}) {
const rewrite = require("./git-rewrite");
if (!ACTIONS.has(action) && !rewrite.ACTIONS.has(action)) {
throw bad("EBADACTION", `unknown git action ${action}`);
}
// A rebase (ours or a terminal's) lets through only what finishes it:
// resolving files, Continue/Skip/Abort. A `commit` mid-rebase would make a
// commit the rebase never planned; shelves and the ref tools wait too.
if ((await operationInProgress(dir)) === "rebase" && !ALLOWED_WHILE_REBASING.has(action)) {
throw bad("EMERGING", "a rebase is in progress: continue, skip or abort it first");
}
if (rewrite.ACTIONS.has(action)) return rewrite.run(dir, action, body);
if (gitRefs.handles(action, body)) {
return gitRefs.refsAction(dir, action, body, { networkGit, PROTECTED });
}
// Several commits (`shas`), and the paused sequence's own buttons: git-pick.js,
// which keeps every guard below. A single `sha` stays on the path further down.
const pick = require("./git-pick");
if (
pick.PICK_ACTIONS.has(action) ||
((action === "cherry-pick" || action === "revert") && Array.isArray(body.shas))
) {
return pick.runPickAction(dir, action, body);
}
if (BLOCKED_WHILE_MERGING.has(action)) {
const open = await operationInProgress(dir);
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
}
if (action.startsWith("merge") || action === "resolve") return mergeAction(dir, action, body);
@@ -516,29 +583,133 @@ async function runGitAction(dir, action, body = {}) {
return done(action, await git(dir, ["commit", "-m", message]));
}
if (action === "shelve") {
await assertRepoRootFor(dir, "a shelf");
if (!(await isDirty(dir))) throw bad("ENOCHANGES", "nothing to shelve");
const paths =
Array.isArray(body.paths) && body.paths.length ? await resolvePaths(dir, body) : null;
assertAgentConfirmed(opts, body, "shelving");
const shelf = await shelve(dir, {
note: body.note,
// A rename's original path rides along, as in `discard`.
paths:
paths &&
paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path])),
}).catch((e) => {
if (!e.shelf) throw e;
throw Object.assign(
bad(
"ESHELVED",
`git failed after saving your changes on the shelf ${e.shelf.sha.slice(0, 7)}`
),
{ git: e.git, shelf: e.shelf }
);
});
return { ...done(action, shelf.result), shelf: shelf.entry };
}
if (action === "unshelve" || action === "shelf-drop") {
await assertRepoRootFor(dir, "a shelf");
if (action === "shelf-drop") {
// A shelf is the only copy of the work in it: same gate as discard.
if (body.confirm !== true) throw bad("ECONFIRM", "shelf-drop requires confirm: true");
const entry = await findShelf(dir, body.sha);
return { ...done(action, await dropShelf(dir, entry.sha)), sha: entry.sha };
}
const entry = await findShelf(dir, body.sha);
// A restore never mixes with new work: on a clean tree its result is
// exactly the shelf, and a conflict can only be between it and HEAD.
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
assertAgentConfirmed(opts, body, "restoring a shelf");
return applyShelf(dir, entry);
}
if (action === "ignore") return ignorePaths(dir, body);
if (action === "branch-create" || action === "branch-delete") {
return branchAction(dir, action, body);
}
// switch
return switchAction(dir, body, opts);
}
/**
* `switch`. With no `changes` a dirty tree is refused, as it always was: git
* happily carries unrelated edits across a switch, which would silently move
* an agent's half-finished work onto another branch unless the user asked
* for exactly that. `changes: "carry"` asks for it (a plain `git switch`,
* which still refuses when a carried file would be overwritten —
* ECARRYCONFLICT with the files), `changes: "shelve"` puts the changes on a
* shelf first and restores it at once if the switch then fails. Never
* --force, --merge or --discard-changes. A successful switch lists the
* TARGET branch's shelves, so the UI can offer them back — asking, never
* applying one on its own.
*/
async function switchAction(dir, body, opts) {
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
if (!branch) throw bad("EBADBRANCH", "branch is required");
if (!(await isValidBranchName(dir, branch))) {
throw bad("EBADBRANCH", "branch is not a valid git branch name");
}
if (await isDirty(dir)) {
// git would refuse anyway for conflicting files, but it happily carries
// unrelated edits across a switch — which silently moves an agent's
// half-finished work onto another branch. Refuse the whole case.
throw bad("EDIRTY", "working tree has uncommitted changes");
const changes = body.changes;
if (changes !== undefined && changes !== "carry" && changes !== "shelve") {
throw bad("EBADCHANGES", "changes must be carry or shelve");
}
const dirty = await isDirty(dir);
if (dirty && !changes) throw bad("EDIRTY", "working tree has uncommitted changes");
// Every refusal that needs no switch attempt comes BEFORE a shelf is made,
// so a refused switch never leaves the user's work moved aside.
const { argv, target } = await switchArgv(dir, branch, body);
if (dirty && changes === "shelve") await assertRepoRootFor(dir, "a shelf");
if (dirty) assertAgentConfirmed(opts, body, "switching with uncommitted changes");
let shelved = null;
if (dirty && changes === "shelve") {
const note = typeof body.note === "string" && body.note.trim() ? body.note : "";
try {
shelved = (await shelve(dir, { note })).entry;
} catch (e) {
// Saved but failed half way: put it back before reporting.
if (e.shelf) await restoreOrReport(dir, e.shelf, e);
throw e;
}
}
let result;
try {
result = await git(dir, argv);
} catch (e) {
// Back where the user was: the shelf goes straight back on.
if (shelved) await restoreOrReport(dir, shelved, e);
throw carryConflict(e) || e;
}
const shelves = (await listShelves(dir).catch(() => [])).filter((s) => s.branch === target);
return { ...done("switch", result, target), ...(shelved ? { shelved } : {}), shelves };
}
/**
* Re-apply a shelf a failed switch made, so the user is back where they
* were. If even that fails the work is still on the shelf, and the answer
* (ESHELVED, 409) says which one rather than leaving the user to find it.
*/
async function restoreOrReport(dir, shelf, cause) {
const back = await applyShelf(dir, shelf).catch((err) => err);
if (!(back instanceof Error) && !back.kept) return;
throw Object.assign(
bad(
"ESHELVED",
`git failed and your changes are on the shelf ${shelf.sha.slice(0, 7)}: restore them from the Shelves list`
),
{ git: cause.git, shelf }
);
}
/** The `git switch` argv for `branch`, after every check that needs no switch. */
async function switchArgv(dir, branch, body) {
const { branches } = await listBranches(dir);
const exists = branches.includes(branch);
if (body.create) {
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
return done(action, await git(dir, ["switch", "-c", branch]), branch);
return { argv: ["switch", "-c", branch], target: branch };
}
if (!exists) {
// A branch only a remote has (fetched, never checked out here): its local
@@ -559,15 +730,220 @@ async function runGitAction(dir, action, body = {}) {
}
throw bad("ENOBRANCH", `branch ${branch} does not exist locally or on a remote`);
}
return done(
action,
await git(dir, ["switch", "-c", pick.branch, "--track", `refs/remotes/${pick.name}`]),
pick.branch
);
return {
argv: ["switch", "-c", pick.branch, "--track", `refs/remotes/${pick.name}`],
target: pick.branch,
};
}
const busyAt = await branchCheckedOutAt(dir, branch);
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
return done(action, await git(dir, ["switch", branch]), branch);
return { argv: ["switch", branch], target: branch };
}
/**
* git's "would be overwritten by checkout" refusal as ECARRYCONFLICT carrying
* the files it named (tracked edits and untracked files alike), or null.
*/
function carryConflict(e) {
const stderr = e?.git?.stderr || "";
if (!/would be overwritten by checkout/.test(stderr)) return null;
const files = [];
let inList = false;
for (const line of stderr.split("\n")) {
if (/would be overwritten by checkout/.test(line)) inList = true;
else if (inList && /^\s/.test(line) && line.trim()) files.push(line.trim());
else inList = false;
}
return Object.assign(
bad("ECARRYCONFLICT", "your changes would be overwritten by the switch: shelve them instead"),
{ files, git: e.git }
);
}
/**
* Refuse a write that moves files under an agent the lane reports as working,
* unless the request says the user saw that and confirmed (`confirm: true`).
*/
function assertAgentConfirmed(opts, body, what) {
if (opts?.agentWorking && body.confirm !== true) {
throw bad(
"EAGENTRUNNING",
`an agent is working in this workspace: confirm ${what} under it (confirm: true)`
);
}
}
/** A stash is repo-wide, so only a lane at the repo root may shelve or restore. */
async function assertRepoRootFor(dir, what) {
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad(
"ESUBDIR",
`${what} holds changes of the whole repository; use a workspace at the repo root`
);
}
}
/** What marks a stash entry as one AgentHub made — the only ones it lists or touches. */
const SHELF_PREFIX = "agenthub-shelf: ";
const SHELF_SEP = " · ";
const MAX_NOTE_LEN = 200;
/**
* Every shelf in the repo, newest first: `{ref, sha, branch, note, createdAt,
* files: [{path, status}]}`. Only entries whose message carries SHELF_PREFIX
* are listed — any other stash is the user's own and never shown or touched.
* A write addresses an entry by `sha`, never by its `stash@{n}`, which a
* `git stash` run in a terminal meanwhile would shift.
*/
async function listShelves(dir) {
const out = await git(dir, ["stash", "list", "--format=%gd%x00%H%x00%gs"]).catch(() => ({
stdout: "",
}));
const shelves = [];
for (const line of out.stdout.split("\n")) {
if (!line) continue;
const [ref, sha, subject = ""] = line.split("\0");
const at = subject.indexOf(SHELF_PREFIX);
if (at < 0 || !SHA_RE.test(sha || "")) continue;
const [branch, createdAt, ...rest] = subject.slice(at + SHELF_PREFIX.length).split(SHELF_SEP);
shelves.push({
ref,
sha,
branch: branch || "",
note: rest.join(SHELF_SEP),
createdAt: createdAt || null,
files: await shelfFiles(dir, sha),
});
}
return shelves;
}
/** The files a shelf holds: its tracked changes, plus its untracked files as `??`. */
async function shelfFiles(dir, sha) {
const files = [];
const none = () => ({ stdout: "" });
const tracked = await git(dir, [
"diff",
"--no-renames",
"--name-status",
"-z",
`${sha}^1`,
sha,
]).catch(none);
const parts = tracked.stdout.split("\0").filter(Boolean);
for (let i = 0; i + 1 < parts.length; i += 2)
files.push({ path: parts[i + 1], status: parts[i] });
const untracked = await git(dir, ["ls-tree", "-r", "-z", "--name-only", `${sha}^3`]).catch(none);
for (const p of untracked.stdout.split("\0").filter(Boolean)) {
files.push({ path: p, status: "??" });
}
return files;
}
/** The shelf named by `sha` (full or abbreviated), or EBADSHA / ENOSHELF. */
async function findShelf(dir, sha) {
if (typeof sha !== "string" || !SHA_RE.test(sha)) throw bad("EBADSHA", "sha must be a shelf id");
const want = sha.toLowerCase();
const entry = (await listShelves(dir)).find((s) => s.sha.startsWith(want));
if (!entry) throw bad("ENOSHELF", `no shelf ${sha}`);
return entry;
}
/**
* `git stash push --include-untracked` under an AgentHub message — the
* current branch, the time and an optional one-line note — of everything or
* of exactly `paths` (already checked by `resolvePaths`).
*/
async function shelve(dir, { note, paths } = {}) {
const branch =
(
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
).stdout.trim() || "(detached)";
const text =
typeof note === "string"
? note
.replace(/[\r\n\t]+/g, " ")
.trim()
.slice(0, MAX_NOTE_LEN)
: "";
const message = `${SHELF_PREFIX}${branch}${SHELF_SEP}${new Date().toISOString()}${
text ? `${SHELF_SEP}${text}` : ""
}`;
const before = await stashTop(dir);
// A stash can fail AFTER it saved the entry (its internal reset could not
// lock a ref): the work is then on a shelf, and the error says which one.
const saved = async (e) => {
const top = await stashTop(dir);
if (!top || top === before) return e;
const entry = (await listShelves(dir)).find((s) => s.sha === top);
return entry ? Object.assign(e, { shelf: entry }) : e;
};
// NOT the literal-pathspec `git` wrapper: under GIT_LITERAL_PATHSPECS a
// stash saves the untracked files but never cleans them off the tree (its
// internal clean uses a magic pathspec), so each path is made literal by
// its own `:(literal)` prefix instead.
const result = await rawGit(dir, [
"stash",
"push",
"--include-untracked",
"-m",
message,
...(paths && paths.length ? ["--", ...paths.map((p) => `:(literal)${p}`)] : []),
]).catch(async (e) => {
throw await saved(e);
});
const top = await stashTop(dir);
// "No local changes to save" exits 0 and makes nothing.
if (!top || top === before) throw bad("ENOCHANGES", "nothing to shelve");
const entry = (await listShelves(dir)).find((s) => s.sha === top);
return { result, entry };
}
/** The newest stash entry's commit, or "". */
async function stashTop(dir) {
return (
await git(dir, ["rev-parse", "-q", "--verify", "refs/stash"]).catch(() => ({ stdout: "" }))
).stdout.trim();
}
/** `git stash drop` of the entry whose commit is `sha`, its index looked up at drop time. */
async function dropShelf(dir, sha) {
const entry = (await listShelves(dir)).find((s) => s.sha === sha);
if (!entry) throw bad("ENOSHELF", `no shelf ${sha}`);
return git(dir, ["stash", "drop", "-q", entry.ref]);
}
/**
* Put a shelf back on a clean tree. `--index` first, so what was staged comes
* back staged; when only the index cannot be rebuilt git refuses before it
* touches the tree, and the shelf is applied without it. A clean apply drops
* the entry; a conflicted one KEEPS it and leaves the unmerged files to the
* `resolve` flow (ours = the branch, theirs = the shelf).
*/
async function applyShelf(dir, entry) {
const kept = (e, conflicts) => ({
...done("unshelve", e.git || { stdout: "", stderr: "" }),
sha: entry.sha,
conflicts,
kept: true,
});
let result;
try {
result = await git(dir, ["stash", "apply", "--index", entry.sha]);
} catch (e) {
const conflicts = await unmergedPaths(dir);
if (conflicts.length) return kept(e, conflicts);
if (await isDirty(dir)) throw e;
try {
result = await git(dir, ["stash", "apply", entry.sha]);
} catch (e2) {
const left = await unmergedPaths(dir);
if (!left.length) throw e2;
return kept(e2, left);
}
}
await dropShelf(dir, entry.sha);
return { ...done("unshelve", result), sha: entry.sha, conflicts: [], kept: false };
}
/** Paths git still holds as unmerged, repo-wide. */
@@ -628,6 +1004,14 @@ async function mergeAction(dir, action, body) {
}
const open = await operationInProgress(dir);
if (!open) throw bad("ENOMERGE", "no merge, cherry-pick or revert in progress");
// The banner's Complete/Abort on a rebase are its Continue/Abort.
if (open === "rebase") {
return require("./git-rewrite").run(
dir,
action === "merge-abort" ? "rebase-abort" : "rebase-continue",
body
);
}
if (action === "merge-abort") return done(action, await git(dir, [open, "--abort"]));
// merge-commit
const left = await unmergedPaths(dir);
@@ -986,6 +1370,23 @@ async function branchAction(dir, action, body) {
if (base && !(await isValidBranchName(dir, base))) {
throw bad("EBADBRANCH", "base is not a valid git ref name");
}
if (body.switch === true) {
// Create & switch: the uncommitted changes come along (git never
// refuses that from HEAD; from another base a clash is ECARRYCONFLICT).
// The working tree is left as it is, so no running-agent confirm.
// operationInProgress reports a rebase too.
const open = await operationInProgress(dir);
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
try {
return done(
action,
await git(dir, ["switch", "-c", branch, ...(base ? [base] : [])]),
branch
);
} catch (e) {
throw carryConflict(e) || e;
}
}
// No switch: creating a branch to spawn a sibling worktree lane from must
// not move the branch the agent in this pane is standing on.
return done(action, await git(dir, ["branch", branch, ...(base ? [base] : [])]), branch);
@@ -1024,4 +1425,24 @@ function done(action, result, branch) {
return { action, ok: true, output, ...(branch ? { branch } : {}) };
}
module.exports = { runGitAction, validateRemoteUrl, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
module.exports = {
runGitAction,
listShelves,
validateRemoteUrl,
ACTIONS,
PROTECTED,
NETWORK_TIMEOUT_MS,
SHELF_PREFIX,
// The shared guards git-rewrite.js / git-conflicts.js build on.
_internal: {
networkGit,
pushRemote,
isDirty,
commitSha,
unmergedPaths,
resolvePaths,
validateRemoteUrl,
PROTECTED,
MAX_MESSAGE_LEN,
},
};
+286
View File
@@ -0,0 +1,286 @@
/**
* @file The multi-commit cherry-pick / revert WRITES, and the buttons of the
* banner a paused one shows (like GitKraken, GitLens and JetBrains):
*
* - `cherry-pick` / `revert` with `shas: [...]` — 1 to 50 non-merge
* commits, applied in git's own sequencer (`git cherry-pick A B C`) in
* the order `pickPlan` fixes (a pick oldest → newest, a revert newest →
* oldest). The single-`sha` body stays git-ops.js's, unchanged.
* - `pick-continue` — `git <op> --continue` once no file is unmerged.
* - `pick-skip` — `git <op> --skip`, `confirm: true` (it drops the
* resolutions made for the commit it stopped at).
* - `pick-abort` — `git <op> --abort`, `confirm: true`: back to where the
* operation started. Unlike `merge-abort` it also ends a sequence whose
* *_HEAD is gone (a commit made in between) but whose todo is not.
* - `undo-pick` — `reset --soft <from>` after a pick/revert this panel ran,
* `confirm: true`: only while HEAD is still the `to` it produced
* (ESTALE), `from` is its ancestor, nothing in between is a merge or on
* any remote-tracking branch (EPUSHED), from the repo root.
*
* The guards are git-ops.js's: argv only, a sha validated and resolved before
* git sees it as a revision, a clean tree and the repo-root lane to start one
* (an abort can then never take uncommitted work with it), nothing while a
* rebase or another operation is open (EMERGING), and the route's lane lock.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const worktree = require("./worktree");
const history = require("./git-history");
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
/** The actions this module owns (git-ops.js dispatches them here). */
const PICK_ACTIONS = new Set(["pick-continue", "pick-skip", "pick-abort", "undo-pick"]);
/** A commit id: abbreviated or full. */
const SHA_RE = /^[0-9a-f]{4,64}$/i;
/** Most commits one `undo-pick` takes back. */
const MAX_UNDO = 50;
/** `--continue` takes the message git prepared; never an editor. */
const NO_EDITOR = { env: { GIT_EDITOR: "true" } };
function bad(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
function out(result) {
return [result?.stdout, result?.stderr].filter(Boolean).join("").trim();
}
async function rebaseInProgress(dir) {
for (const name of ["rebase-merge", "rebase-apply"]) {
const rel = (await git(dir, ["rev-parse", "--git-path", name])).stdout.trim();
if (fs.existsSync(path.resolve(dir, rel))) return true;
}
return false;
}
async function unmergedPaths(dir) {
return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout
.split("\0")
.filter(Boolean);
}
async function head(dir) {
return (await git(dir, ["rev-parse", "HEAD"])).stdout.trim();
}
async function assertRepoRoot(dir, what) {
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad(
"ESUBDIR",
`${what} changes the whole repository; run it from a workspace at the repo root`
);
}
}
/** The cherry-pick or revert open here (by *_HEAD, else by the sequencer). */
async function openPick(dir) {
const op = await worktree.operationInProgress(dir);
if (op === "cherry-pick" || op === "revert") return op;
// A merge or a rebase (the rewrite tools' own) is never a pick here.
if (op) return null;
const todo = await history.sequencerTodo(dir);
if (!todo.length) return null;
return todo[0].verb === "revert" ? "revert" : "cherry-pick";
}
/** The commits `from..to` made, oldest first. */
async function madeBetween(dir, from, to) {
if (!from || from === to) return [];
const { stdout } = await git(dir, [
"log",
"--reverse",
`--max-count=${MAX_UNDO * 2}`,
"--format=%H%x1f%h%x1f%s%x1e",
`${from}..${to}`,
]);
return stdout
.split("\x1e")
.map((r) => r.replace(/^\n/, ""))
.filter((r) => r.trim())
.map((r) => {
const [sha, short, subject] = r.split("\x1f");
return { sha, short, subject };
});
}
/** What every answer carries: where it started, where HEAD is, what it made. */
async function result(dir, action, gitResult, from, extra = {}) {
const to = await head(dir).catch(() => null);
const state = await history.sequenceState(dir);
return {
action,
ok: true,
output: out(gitResult),
from: state.active && state.startedFrom ? state.startedFrom : from,
to,
commits: await madeBetween(
dir,
state.active && state.startedFrom ? state.startedFrom : from,
to
),
conflicts: state.active ? state.conflicts : [],
paused: !!state.active,
...extra,
};
}
/** `cherry-pick` / `revert` with `shas`: one sequence, in pickPlan's order. */
async function startSequence(dir, action, body) {
if (await rebaseInProgress(dir)) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
const open = (await worktree.operationInProgress(dir)) || (await openPick(dir));
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
const commits = await history.pickPlan(dir, action, body.shas);
await assertRepoRoot(dir, `a ${action}`);
const { dirty, untracked } = await worktree.statusCounts(dir);
if (dirty + untracked > 0) throw bad("EDIRTY", "working tree has uncommitted changes");
const from = await head(dir);
const shas = commits.map((c) => c.sha);
const args = action === "revert" ? ["revert", "--no-edit", ...shas] : ["cherry-pick", ...shas];
try {
const r = await git(dir, args, NO_EDITOR);
return result(dir, action, r, from, { applied: commits });
} catch (e) {
const state = await history.sequenceState(dir);
if (state.active) {
// Stopped on a conflict, or on a commit whose changes are already here
// (no conflict, nothing to commit): paused, finished from the banner.
const empty = !state.conflicts.length;
return result(dir, action, e.git, from, { applied: commits, ...(empty ? { empty } : {}) });
}
// Nothing paused: git refused outright (no identity, a failing signer).
// The tree was clean before, so the commits' own paths go back to HEAD —
// nothing else is touched, and no reset runs (git-ops.js's single-sha
// path does the same).
const touched = new Set();
for (const sha of shas) {
const r = await git(dir, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-z", sha]);
for (const p of r.stdout.split("\0")) if (p) touched.add(p);
}
if (touched.size) {
await git(dir, [
"restore",
"--source=HEAD",
"--staged",
"--worktree",
"--",
...touched,
]).catch(() => {});
}
throw e;
}
}
/** pick-continue / pick-skip / pick-abort. */
async function sequenceAction(dir, action, body) {
if (await rebaseInProgress(dir)) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
if ((action === "pick-abort" || action === "pick-skip") && body.confirm !== true) {
throw bad("ECONFIRM", `${action} requires confirm: true`);
}
const op = await openPick(dir);
if (!op) throw bad("ENOMERGE", "no cherry-pick or revert in progress");
const from = await history.sequencerHead(dir);
if (action === "pick-abort") {
const r = await git(dir, [op, "--abort"]);
return { action, ok: true, output: out(r), to: await head(dir), conflicts: [], paused: false };
}
if (action === "pick-continue") {
const left = await unmergedPaths(dir);
if (left.length) {
throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
}
}
try {
const r = await git(dir, [op, action === "pick-skip" ? "--skip" : "--continue"], NO_EDITOR);
return result(dir, action, r, from);
} catch (e) {
const state = await history.sequenceState(dir);
if (state.active && state.conflicts.length) return result(dir, action, e.git, from);
if (state.active && action === "pick-continue") {
// Resolved to exactly what the branch already had: nothing to commit.
const staged = await git(dir, ["diff", "--cached", "--quiet"]).then(
() => false,
() => true
);
if (!staged) throw bad("EEMPTY", "this commit's changes are already here: skip it instead");
}
if (state.active) return result(dir, action, e.git, from, { empty: true });
throw e;
}
}
/** undo-pick: `reset --soft <from>`, only over what the pick itself made. */
async function undoPick(dir, body) {
if (body.confirm !== true) throw bad("ECONFIRM", "undo-pick requires confirm: true");
if (await rebaseInProgress(dir)) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
const open = (await worktree.operationInProgress(dir)) || (await openPick(dir));
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
for (const k of ["from", "to"]) {
if (typeof body[k] !== "string" || !SHA_RE.test(body[k])) {
throw bad("EBADSHA", `${k} must be a commit id`);
}
}
const resolve = (s) =>
git(dir, ["rev-parse", "-q", "--verify", `${s}^{commit}`]).then(
(r) => r.stdout.trim(),
() => ""
);
const from = await resolve(body.from);
const to = await resolve(body.to);
if (!from || !to) throw bad("EBADSHA", "no such commit");
if (to !== (await head(dir))) {
throw bad("ESTALE", "the branch moved since the pick: refresh and retry");
}
await assertRepoRoot(dir, "undoing a pick");
const ancestor = await git(dir, ["merge-base", "--is-ancestor", from, to]).then(
() => true,
() => false
);
if (!ancestor || from === to) throw bad("EBADSHA", "from is not before to on this branch");
const made = await madeBetween(dir, from, to);
if (!made.length || made.length > MAX_UNDO) {
throw bad("EBADSHA", `undo takes back 1 to ${MAX_UNDO} commits`);
}
if ((await git(dir, ["rev-list", "--merges", `${from}..${to}`])).stdout.trim()) {
throw bad("EMERGECOMMIT", "a merge commit is in that range");
}
// Every later commit contains the first one, so a remote holding any of
// them holds the first.
const on = (
await git(dir, [
"for-each-ref",
"--contains",
made[0].sha,
"--format=%(refname:short)",
"refs/remotes",
])
).stdout.trim();
if (on) {
throw bad("EPUSHED", `those commits are already on ${on.split("\n")[0]}; revert them instead`);
}
// --soft: only the branch moves; the changes stay staged, the working tree
// (an agent's live edits included) is never touched.
const r = await git(dir, ["reset", "--soft", from]);
return { action: "undo-pick", ok: true, output: out(r), to: from, undone: made };
}
/** Entry point from git-ops.js's `runGitAction`. */
async function runPickAction(dir, action, body = {}) {
if (action === "undo-pick") return undoPick(dir, body);
if (PICK_ACTIONS.has(action)) return sequenceAction(dir, action, body);
return startSequence(dir, action, body);
}
module.exports = { runPickAction, PICK_ACTIONS };
+524
View File
@@ -0,0 +1,524 @@
/**
* @file Branch and tag tools for a lane's git bar (the Branches panel): the
* read `listRefs` behind `GET /api/lanes/:id/git/refs` — local branches with
* their upstream, ahead/behind, last commit and why a delete would be
* refused; remote branches grouped by remote; tags with their target commit
* and, for an annotated one, its message, tagger and date — and the writes
* git-ops.js dispatches here: `branch-rename`, `branch-upstream` (set or
* unset), `branch-create` when it names a `from` start point or asks to
* `switch`, `tag-create`, `tag-delete` and `tag-push`.
*
* The rules are git-ops.js's own:
* - argv only, through worktree.js's `git()` (which already runs as the
* folder's member under S3) — never a shell string.
* - every ref the request names is LOOKED UP: a branch, remote branch or
* tag must exist under refs/heads, refs/remotes or refs/tags, and git is
* handed the full refname that lookup found (or a sha `rev-parse
* --verify <x>^{commit}` confirmed) — never the request's text, so no
* option, revision expression or `..` can ride in as a "ref".
* - a NEW name is checked by git itself (`check-ref-format`), and a tag
* name may not start with `-` (git accepts `refs/tags/-x`; argv must not).
* - branches and tags are repo-wide, so every write here but `branch-create`
* (which keeps git-ops.js's existing reach) runs only from a lane at the
* repo root (ESUBDIR), and none runs while a merge, cherry-pick, revert or
* rebase is open (EMERGING).
* - `tag-delete` is irreversible, so it needs `confirm: true`; renaming or
* re-pointing a protected branch (main/master/development/develop) is
* refused like deleting one.
* - `tag-push` pushes ONE looked-up tag (`refs/tags/<name>:refs/tags/<name>`)
* to `origin` (else the only remote), never `--tags`, never forced, with
* the same credentials flow as push. Deleting a remote tag is not here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const {
git: rawGit,
isValidBranchName,
listWorktrees,
operationInProgress,
statusCounts,
} = require("./worktree");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
/** The writes this module answers (git-ops.js adds them to its ACTIONS). */
const REF_ACTIONS = ["branch-rename", "branch-upstream", "tag-create", "tag-delete", "tag-push"];
/** Error codes of this module that are the request's fault (HTTP 400). */
const BAD_REQUEST_CODES = ["EBADREF", "EBADTAG", "ETAGEXISTS", "ENOTAG", "ENOUPSTREAM"];
/** A commit as the request may name it: an abbreviated or full hex sha. */
const SHA_RE = /^[0-9a-f]{4,64}$/i;
/** Longest tag message accepted. */
const MAX_MESSAGE_LEN = 4000;
/** How many refs of each kind the read returns. */
const MAX_REFS = 1000;
function bad(code, message) {
return Object.assign(new Error(message), { code });
}
const str = (v) => (typeof v === "string" ? v.trim() : "");
/** Whether `dir` is a subdirectory of its repo (not the root). */
async function isSubdir(dir) {
return Boolean((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim());
}
async function assertRepoRoot(dir, what) {
if (await isSubdir(dir)) {
throw bad(
"ESUBDIR",
`${what} changes the whole repository; run it from a workspace at the repo root`
);
}
}
/** Throw EMERGING while a rebase, merge, cherry-pick or revert is open. */
async function assertNoOperation(dir) {
for (const name of ["rebase-merge", "rebase-apply"]) {
const rel = (await git(dir, ["rev-parse", "--git-path", name])).stdout.trim();
if (fs.existsSync(path.resolve(dir, rel))) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
}
const open = await operationInProgress(dir);
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
}
/** `[full refname, short name, symref]` for every ref under `namespaces`. */
async function refsUnder(dir, namespaces) {
return (
await git(dir, [
"for-each-ref",
"--format=%(refname)%00%(refname:short)%00%(symref)",
...namespaces,
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"));
}
/** The full refname of local branch `name`, or null. */
async function localBranchRef(dir, name) {
if (!name) return null;
const full = `refs/heads/${name}`;
return (await refsUnder(dir, [full])).some(([r]) => r === full) ? full : null;
}
/** The full refname of tag `name`, or null. */
async function tagRef(dir, name) {
if (!name) return null;
const full = `refs/tags/${name}`;
return (await refsUnder(dir, [full])).some(([r]) => r === full) ? full : null;
}
/** The full refname of remote-tracking branch `name` (`origin/x`), or null. */
async function remoteBranchRef(dir, name) {
if (!name) return null;
const full = `refs/remotes/${name}`;
const hit = (await refsUnder(dir, [full])).find(([r]) => r === full);
return hit && !hit[2] ? full : null; // a remote's symbolic HEAD is no branch
}
/** The full sha of the commit `rev` names, or "". `rev` is a looked-up ref or a hex sha. */
async function commitOf(dir, rev) {
return git(dir, ["rev-parse", "-q", "--verify", `${rev}^{commit}`]).then(
(r) => r.stdout.trim(),
() => ""
);
}
/**
* A start point from the request, LOOKED UP: a local branch, a remote
* branch, a tag (short or full refname — a name that is both a branch and a
* tag must be spelled out in full), or a hex sha of a commit that exists.
* Returns `{rev, kind}` — `rev` the full refname or full sha git is handed —
* or throws EBADREF / EAMBIGUOUS.
*/
async function resolveStart(dir, from) {
const name = str(from);
if (!name || name.length > 1024 || name.startsWith("-")) {
throw bad("EBADREF", "from must be a branch, tag or commit");
}
const refs = await refsUnder(dir, ["refs/heads", "refs/remotes", "refs/tags"]);
const hits = refs.filter(
([full, , symref]) =>
!symref &&
(full === name ||
full === `refs/heads/${name}` ||
full === `refs/remotes/${name}` ||
full === `refs/tags/${name}`)
);
if (hits.length > 1) {
throw bad("EAMBIGUOUS", `${name} names several refs (${hits.map((h) => h[0]).join(", ")})`);
}
if (hits.length === 1) {
const full = hits[0][0];
const sha = await commitOf(dir, full);
if (!sha) throw bad("EBADREF", `${name} does not point at a commit`);
const kind = full.startsWith("refs/heads/")
? "branch"
: full.startsWith("refs/remotes/")
? "remote"
: "tag";
return { rev: full, sha, kind };
}
if (SHA_RE.test(name)) {
const sha = await commitOf(dir, name);
if (sha) return { rev: sha, sha, kind: "commit" };
}
throw bad("EBADREF", `no branch, tag or commit ${name}`);
}
/** A new tag name, checked by git, or EBADTAG. */
async function validTagName(dir, raw) {
const name = str(raw);
if (!name || name.startsWith("-") || name.length > 255) {
throw bad("EBADTAG", "tag is not a valid git tag name");
}
const ok = await git(dir, ["check-ref-format", `refs/tags/${name}`]).then(
() => true,
() => false
);
if (!ok) throw bad("EBADTAG", "tag is not a valid git tag name");
return name;
}
/** A branch name from the request, checked by git, or EBADBRANCH. */
async function validBranchName(dir, raw, field = "branch") {
const name = str(raw);
if (!name) throw bad("EBADBRANCH", `${field} is required`);
if (!(await isValidBranchName(dir, name))) {
throw bad("EBADBRANCH", `${field} is not a valid git branch name`);
}
return name;
}
async function isDirty(dir) {
const { dirty, untracked } = await statusCounts(dir);
return dirty + untracked > 0;
}
function done(action, result, extra = {}) {
const output = [result.stdout, result.stderr].filter(Boolean).join("").trim();
return { action, ok: true, output, ...extra };
}
/**
* Whether git-ops.js should hand this action to `refsAction`: one of
* REF_ACTIONS, or a `branch-create` naming a `from` start point. A plain
* `{branch, switch: true}` (Create & switch from HEAD) stays git-ops.js's:
* `git switch -c` there carries uncommitted changes along, which is its
* whole point — here a switch needs a clean tree.
*/
function handles(action, body = {}) {
if (REF_ACTIONS.includes(action)) return true;
return action === "branch-create" && body.from !== undefined;
}
/**
* Run one branch/tag write.
* @param {string} dir The lane's cwd.
* @param {string} action See `handles`.
* @param {object} body The request body.
* @param {{networkGit: Function, PROTECTED: Set<string>}} helpers git-ops.js's
* authenticated network runner and protected-branch set (passed in, so the
* two modules do not require each other).
*/
async function refsAction(dir, action, body = {}, { networkGit, PROTECTED }) {
if (action === "branch-create") return createBranch(dir, body);
await assertNoOperation(dir);
await assertRepoRoot(dir, action.startsWith("tag") ? "a tag" : "a branch change");
if (action === "branch-rename") {
const branch = await validBranchName(dir, body.branch);
const name = await validBranchName(dir, body.name, "name");
if (!(await localBranchRef(dir, branch))) {
throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
}
if (PROTECTED.has(branch)) throw bad("EPROTECTED", `branch ${branch} is protected`);
if (name === branch) throw bad("EBADBRANCH", "the new name is the old one");
if (await localBranchRef(dir, name)) {
throw bad("EBRANCHEXISTS", `branch ${name} already exists`);
}
// -m, never -M: git refuses to overwrite an existing branch on its own.
return done(action, await git(dir, ["branch", "-m", branch, name]), { branch: name });
}
if (action === "branch-upstream") {
const branch = await validBranchName(dir, body.branch);
if (!(await localBranchRef(dir, branch))) {
throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
}
const wanted = str(body.upstream);
if (!wanted) {
const has = await git(dir, ["config", "--get", `branch.${branch}.merge`]).then(
(r) => Boolean(r.stdout.trim()),
() => false
);
if (!has) throw bad("ENOUPSTREAM", `branch ${branch} has no upstream`);
return done(action, await git(dir, ["branch", "--unset-upstream", branch]), { branch });
}
const ref = await remoteBranchRef(dir, wanted);
if (!ref) throw bad("ENOBRANCH", `no remote branch ${wanted}`);
return done(action, await git(dir, ["branch", `--set-upstream-to=${ref}`, branch]), {
branch,
upstream: wanted,
});
}
if (action === "tag-create") {
const name = await validTagName(dir, body.tag ?? body.name);
if (await tagRef(dir, name)) throw bad("ETAGEXISTS", `tag ${name} already exists`);
const message = typeof body.message === "string" ? body.message.trim() : "";
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "tag message is too long");
let sha;
if (body.from !== undefined && body.from !== null && body.from !== "") {
sha = (await resolveStart(dir, body.from)).sha;
} else {
sha = await commitOf(dir, "HEAD");
if (!sha) throw bad("ENOCOMMITS", "there is no commit to tag yet");
}
// An annotated tag when a message is given (its tagger is the account's
// own git identity); `-m` carries the message as ONE argv element.
const args = message ? ["tag", "-a", "-m", message, name, sha] : ["tag", name, sha];
return done(action, await git(dir, args), { tag: name });
}
if (action === "tag-delete") {
if (body.confirm !== true) throw bad("ECONFIRM", "tag-delete requires confirm: true");
const name = await validTagName(dir, body.tag ?? body.name);
if (!(await tagRef(dir, name))) throw bad("ENOTAG", `no tag ${name}`);
return done(action, await git(dir, ["tag", "-d", name]), { tag: name });
}
// tag-push
const name = await validTagName(dir, body.tag ?? body.name);
const ref = await tagRef(dir, name);
if (!ref) throw bad("ENOTAG", `no tag ${name}`);
const remotes = (await git(dir, ["remote"])).stdout.split("\n").filter(Boolean);
const remote = remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : null;
if (!remote) {
throw bad(
"ENOREMOTE",
remotes.length
? "several remotes and no origin: push the tag from a terminal"
: "this repository has no remote to push to"
);
}
// ONE tag by its full refname on both sides — no --tags, no force: a tag
// the remote already holds at another commit is rejected in git's words.
return done(action, await networkGit(dir, ["push", remote, `${ref}:${ref}`], body), {
tag: name,
remote,
});
}
/**
* `branch-create` with a looked-up `from` and/or `switch: true`. Without
* `switch` the working tree is untouched (git-ops.js's rule); with it the
* tree must be clean (EDIRTY) and no operation open — carrying changes
* across is not this module's to decide.
*/
async function createBranch(dir, body) {
const branch = await validBranchName(dir, body.branch);
if (await localBranchRef(dir, branch)) {
throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
}
const start =
body.from === undefined || body.from === null || body.from === ""
? null
: await resolveStart(dir, body.from);
if (body.switch === true) {
await assertNoOperation(dir);
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
}
// A local or remote branch is passed by its full refname (a remote one so
// git sets it as the upstream, like JetBrains' "new branch from origin/x");
// a tag or a commit by its sha.
const rev = start
? start.kind === "branch" || start.kind === "remote"
? start.rev
: start.sha
: null;
const out = [await git(dir, ["branch", branch, ...(rev ? [rev] : [])])];
if (body.switch === true) out.push(await git(dir, ["switch", branch]));
return done(
"branch-create",
{ stdout: out.map((r) => r.stdout).join(""), stderr: out.map((r) => r.stderr).join("") },
{ branch, ...(body.switch === true ? { switched: true } : {}) }
);
}
/** "ahead 2, behind 1" / "gone" / "" → counts. */
function parseTrack(track, hasUpstream) {
if (!hasUpstream) return { ahead: null, behind: null, gone: false };
if (track === "gone") return { ahead: null, behind: null, gone: true };
const ahead = Number(/ahead (\d+)/.exec(track)?.[1] || 0);
const behind = Number(/behind (\d+)/.exec(track)?.[1] || 0);
return { ahead, behind, gone: false };
}
/**
* Every branch and tag of the repo at `dir`, for the Branches panel.
* @returns {Promise<{current: string|null, head: string|null, local: object[],
* remotes: {remote: string, branches: object[]}[], tags: object[], truncated: boolean}>}
*/
async function listRefs(dir, { protectedNames = new Set() } = {}) {
const SEP = "%00";
const head = await commitOf(dir, "HEAD");
const current =
(
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
).stdout.trim() || null;
let truncated = false;
const cap = (list) => {
if (list.length > MAX_REFS) truncated = true;
return list.slice(0, MAX_REFS);
};
const merged = new Set(
head
? (
await git(dir, [
"for-each-ref",
"--merged",
"HEAD",
"--format=%(refname:short)",
"refs/heads",
])
).stdout
.split("\n")
.filter(Boolean)
: []
);
const here = await git(dir, ["rev-parse", "--show-toplevel"]).then(
(r) => r.stdout.trim(),
() => ""
);
const held = new Map();
for (const w of await listWorktrees(dir).catch(() => [])) {
if (w.branch && path.resolve(w.path) !== path.resolve(here)) held.set(w.branch, w.path);
}
const localFmt = [
"%(refname:short)",
"%(objectname)",
"%(upstream:short)",
"%(upstream:track,nobracket)",
"%(contents:subject)",
"%(committerdate:iso-strict)",
].join(SEP);
const local = cap(
(
await git(dir, [
"for-each-ref",
"--sort=-committerdate",
`--format=${localFmt}`,
"refs/heads",
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => {
const [name, sha, upstream, track, subject, date] = l.split("\0");
return {
name,
sha,
current: name === current,
upstream: upstream || null,
...parseTrack(track || "", Boolean(upstream)),
subject,
date: date || null,
merged: merged.has(name),
worktree: held.get(name) || null,
protected: protectedNames.has(name),
};
})
);
const remoteNames = (await git(dir, ["remote"])).stdout
.split("\n")
.filter(Boolean)
.sort((a, b) => b.length - a.length);
const remoteFmt = [
"%(refname:short)",
"%(objectname)",
"%(symref)",
"%(contents:subject)",
"%(committerdate:iso-strict)",
].join(SEP);
const groups = new Map();
const remoteRows = cap(
(
await git(dir, [
"for-each-ref",
"--sort=-committerdate",
`--format=${remoteFmt}`,
"refs/remotes",
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"))
.filter(([, , symref]) => !symref)
);
for (const [name, sha, , subject, date] of remoteRows) {
const remote = remoteNames.find((r) => name.startsWith(`${r}/`));
if (!remote) continue;
const branch = name.slice(remote.length + 1);
if (!branch || branch === "HEAD") continue;
if (!groups.has(remote)) groups.set(remote, []);
groups.get(remote).push({ name, branch, sha, subject, date: date || null });
}
const remotes = [...remoteNames]
.sort()
.map((remote) => ({ remote, branches: groups.get(remote) || [] }));
const tagFmt = [
"%(refname:short)",
"%(objecttype)",
"%(objectname)",
"%(*objectname)",
"%(contents:subject)",
"%(taggername)",
"%(taggerdate:iso-strict)",
"%(committerdate:iso-strict)",
"%(*committerdate:iso-strict)",
].join(SEP);
const tags = cap(
(
await git(dir, ["for-each-ref", "--sort=-creatordate", `--format=${tagFmt}`, "refs/tags"])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => {
const [name, type, obj, peeled, subject, tagger, taggerDate, cDate, peeledDate] =
l.split("\0");
const annotated = type === "tag";
return {
name,
sha: annotated ? peeled || null : type === "commit" ? obj : null,
annotated,
message: annotated ? subject : null,
tagger: annotated ? tagger || null : null,
date: (annotated ? taggerDate || peeledDate : cDate) || null,
};
})
);
return { current, head: head || null, local, remotes, tags, truncated };
}
module.exports = {
REF_ACTIONS,
BAD_REQUEST_CODES,
handles,
refsAction,
listRefs,
resolveStart,
};
+772
View File
@@ -0,0 +1,772 @@
/**
* @file The dashboard's history-REWRITING and remote-changing git writes, kept
* out of git-ops.js so they read as one policy: rebase onto a branch (with
* Continue / Skip / Abort while it is open), an interactive rebase of a
* branch's commits built from a list (reorder, squash, fixup, reword, drop),
* reset the branch to a commit (soft / mixed / keep, and hard only on a clean
* tree), the Undo those leave behind, push --force-with-lease, delete a remote
* branch, and add / rename / remove a remote. Dispatched from git-ops.js's
* `runGitAction`, so every action here rides the same route, lock and
* same-origin guard as the rest.
*
* The rule these were allowed under: a dashboard action is fine when its
* RECOVERY is a button too, and the server refuses what the UI only
* discourages.
* - A rebase is recoverable while open (`rebase-abort`), and every rewrite
* answers `before`/`after`, which `undo-rewrite` (`reset --keep <before>`,
* only while HEAD is still `after` — ESTALE) turns back.
* - Rewriting a commit any remote-tracking branch already holds is EPUSHED
* unless the request says it knows (`force: true` — the UI shows that a
* force-push will be needed); a force push is `--force-with-lease` against
* the exact remote sha the UI showed (ESTALE when the remote moved), for
* HEAD only, never onto main/master/development/develop unless the server
* sets AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1.
* - `reset --hard` only on a CLEAN tree and with the branch name typed back
* (`confirmBranch`), so nothing uncommitted can be lost; soft/mixed never
* touch the working tree and `keep` refuses to overwrite a local change.
* - Every rewrite runs from a lane at the repo ROOT (ESUBDIR), on a clean
* tree where it moves files, never while another operation is open
* (EMERGING), and names the HEAD it was planned against (ESTALE).
* - A remote's URL passes git-ops.js's `validateRemoteUrl` allowlist, and a
* ref is always looked up among the repo's own, never passed through.
* - argv only. The interactive rebase's todo and reword messages are files
* AgentHub writes into the repo's own git dir (as the folder's account,
* 0600); git is handed `cp` as its sequence editor and `true` as its
* editor, and the todo holds only full commit ids plus `exec git commit
* --amend -F <that file>` lines — no text from the request is ever shell.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { git: rawGit, isValidBranchName, branchRef, operationInProgress } = require("./worktree");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
/** The writes this module owns (git-ops.js dispatches them here). */
const ACTIONS = new Set([
"rebase",
"rebase-continue",
"rebase-skip",
"rebase-abort",
"rebase-interactive",
"reset",
"undo-rewrite",
"force-push",
"remote-branch-delete",
"remote-add",
"remote-rename",
"remote-remove",
"conflict-save",
]);
/** What each new error code answers (the route asks here first). */
const STATUS = {
EBADMODE: 400,
EBADTODO: 400,
EBADREMOTE: 400,
ENOTRELATED: 400,
EDETACHED: 400,
EDEFAULTBRANCH: 400,
ENOREBASE: 409,
ENOCHANGES: 400,
EBADCONTENT: 400,
EBINARY: 400,
ETOOBIG: 400,
ENOFILE: 400,
ESECRET: 403,
EFOREIGN: 403,
EUNSUPPORTED: 400,
};
/** The flags every rebase here runs with: no autostash, no other branch moved. */
const REBASE_CFG = [
"-c",
"rebase.autoStash=false",
"-c",
"rebase.updateRefs=false",
"-c",
"rebase.autoSquash=false",
"-c",
"rebase.missingCommitsCheck=ignore",
];
/** No editor may ever open under the dashboard. */
const NO_EDITOR = { GIT_EDITOR: "true" };
const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
/** A remote name: what `git remote add` accepts, minus anything option-like. */
const REMOTE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
const TODO_ACTIONS = new Set(["pick", "reword", "squash", "fixup", "drop"]);
/** Most commits one interactive rebase may list. */
const MAX_TODO = 300;
function ops() {
return require("./git-ops")._internal;
}
function bad(code, message, extra) {
return Object.assign(new Error(message), { code }, extra || {});
}
const out = (r) => [r.stdout, r.stderr].filter(Boolean).join("").trim();
const done = (action, r, extra) => ({ action, ok: true, output: r ? out(r) : "", ...extra });
async function head(dir) {
return (await git(dir, ["rev-parse", "HEAD"])).stdout.trim();
}
async function currentBranch(dir) {
return (
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
).stdout.trim();
}
async function lines(dir, args) {
return (await git(dir, args).catch(() => ({ stdout: "" }))).stdout.split("\n").filter(Boolean);
}
/** Whether a rebase is open (merge or apply backend). */
async function rebaseOpen(dir) {
return (await operationInProgress(dir)) === "rebase";
}
async function assertRepoRoot(dir, what) {
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad(
"ESUBDIR",
`${what} moves the whole branch; run it from a workspace at the repo root`
);
}
}
async function assertNoOperation(dir) {
const open = await operationInProgress(dir);
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
}
/** HEAD must still be the commit the dialog was drawn for. */
async function assertHead(dir, seen) {
if (typeof seen !== "string" || !FULL_SHA.test(seen)) {
throw bad("EBADSHA", "head (the full HEAD sha the plan showed) is required");
}
if (seen !== (await head(dir))) {
throw bad("ESTALE", "the branch moved since you looked: refresh and retry");
}
}
/** Of `shas`, the ones some remote-tracking branch already contains. */
async function pushedOf(dir, range) {
const all = await lines(dir, ["rev-list", ...range]);
if (!all.length) return { all, pushed: [] };
const unpushed = new Set(await lines(dir, ["rev-list", ...range, "--not", "--remotes"]));
return { all, pushed: all.filter((s) => !unpushed.has(s)) };
}
/** `{sha, short, subject, message, pushed}` for each commit of `range`, oldest first. */
async function commitList(dir, range) {
const raw = (
await git(dir, ["log", "--reverse", "--format=%H%x00%h%x00%s%x00%B%x1e", ...range, "--"]).catch(
() => ({ stdout: "" })
)
).stdout;
const { pushed } = await pushedOf(dir, range);
const isPushed = new Set(pushed);
return raw
.split("\x1e")
.map((r) => r.replace(/^\n/, ""))
.filter(Boolean)
.map((r) => {
const [sha, short, subject, message] = r.split("\0");
return { sha, short, subject, message: (message || "").trim(), pushed: isPushed.has(sha) };
});
}
// ───────────────────────── interactive-rebase scratch files ─────────────────────────
const SCRATCH_PREFIX = "agenthub-rebase-";
async function gitDir(dir) {
return (await git(dir, ["rev-parse", "--absolute-git-dir"])).stdout.trim();
}
/** Single-quote for git's `sh -c` — only ever AgentHub's own paths. */
function shq(p) {
if (/['\n\r\0]/.test(p)) throw bad("EUNSUPPORTED", "the repository path cannot be quoted safely");
return `'${p}'`;
}
/** Write one scratch file as the folder's account (S3: the member, write-as). */
function writeScratch(file, content) {
const runAs = require("./run-as");
const acct = runAs.accountForPath(file);
if (!acct) {
fs.writeFileSync(file, content, { mode: 0o600 });
fs.chmodSync(file, 0o600);
return;
}
runAs.writeAs(acct, file, "", content);
}
/** Remove every scratch file a finished rebase left in the git dir. */
async function cleanScratch(dir) {
const gd = await gitDir(dir).catch(() => null);
if (!gd) return;
let names = [];
try {
names = fs.readdirSync(gd).filter((n) => n.startsWith(SCRATCH_PREFIX));
} catch {
return;
}
const runAs = require("./run-as");
for (const n of names) {
const f = path.join(gd, n);
try {
const acct = runAs.accountForPath(f);
if (acct) runAs.removeAs(acct, f);
else fs.rmSync(f, { force: true });
} catch {
/* best effort: a stray scratch file is harmless and overwritten next time */
}
}
}
function cpPath() {
return ["/bin/cp", "/usr/bin/cp"].find((f) => fs.existsSync(f)) || "cp";
}
// ───────────────────────── rebase ─────────────────────────
/** Run a rebase; a stop (conflict, failed exec) is an operation that STARTED. */
async function runRebase(dir, action, args, env, extra) {
try {
const r = await git(dir, [...REBASE_CFG, ...args], { env: { ...NO_EDITOR, ...env } });
const after = await head(dir);
if (!(await rebaseOpen(dir))) await cleanScratch(dir);
return done(action, r, { ...extra, after, conflicts: [], stopped: await rebaseOpen(dir) });
} catch (e) {
if (!(await rebaseOpen(dir))) {
await cleanScratch(dir);
throw e;
}
const { unmergedPaths } = ops();
return done(action, e.git || null, {
...extra,
after: null,
conflicts: await unmergedPaths(dir),
stopped: true,
});
}
}
/** `rebase {onto, head, force?}`: replay this branch's commits onto a branch. */
async function rebaseOnto(dir, body) {
const onto = typeof body.onto === "string" ? body.onto.trim() : "";
if (!(await isValidBranchName(dir, onto))) throw bad("EBADBRANCH", "onto must be a branch");
await assertRepoRoot(dir, "a rebase");
await assertNoOperation(dir);
if (await ops().isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
await assertHead(dir, body.head);
const ref = await branchRef(dir, onto);
if (!ref) throw bad("ENOBRANCH", `no branch ${onto}`);
const { all, pushed } = await pushedOf(dir, [`${ref}..HEAD`]);
if (pushed.length && body.force !== true) {
throw bad(
"EPUSHED",
`${pushed.length} of the ${all.length} commits to rebase are already pushed: rebasing them needs a force-push`,
{ pushed: pushed.length, total: all.length }
);
}
const before = await head(dir);
return runRebase(
dir,
"rebase",
["rebase", ref],
{},
{ before, needsForcePush: pushed.length > 0 }
);
}
/** rebase-continue / rebase-skip / rebase-abort. */
async function rebaseStep(dir, action, body) {
if (!(await rebaseOpen(dir))) throw bad("ENOREBASE", "no rebase is in progress");
if (action === "rebase-abort") {
if (body.confirm !== true) throw bad("ECONFIRM", "rebase-abort requires confirm: true");
const r = await git(dir, ["rebase", "--abort"]);
await cleanScratch(dir);
return done(action, r, { after: await head(dir), conflicts: [], stopped: false });
}
if (action === "rebase-continue") {
const left = await ops().unmergedPaths(dir);
if (left.length) {
throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
}
return runRebase(dir, action, ["rebase", "--continue"], {}, {});
}
return runRebase(dir, action, ["rebase", "--skip"], {}, {});
}
/**
* The interactive plan's base: `body.base` (an ancestor of HEAD), else the
* parent of the oldest commit no remote-tracking branch holds.
*/
async function interactiveBase(dir, base) {
if (base) {
const sha = await ops().commitSha(dir, base);
const anc = await git(dir, ["merge-base", "--is-ancestor", sha, "HEAD"]).then(
() => true,
() => false
);
if (!anc) throw bad("ENOTRELATED", "the base must be an ancestor of HEAD");
return sha;
}
const unpushed = await lines(dir, ["rev-list", "--reverse", "HEAD", "--not", "--remotes"]);
if (!unpushed.length) return await head(dir);
const parent = await git(dir, ["rev-parse", "-q", "--verify", `${unpushed[0]}^`]).then(
(r) => r.stdout.trim(),
() => ""
);
if (!parent) throw bad("ENOPARENT", "the first commit cannot be rewritten here");
return parent;
}
/**
* The todo for `entries` (oldest first): a squash becomes `fixup` plus one
* `exec git commit --amend -F <file>` at the end of its group carrying the
* combined message, a reword `pick` plus the same exec — so git never opens an
* editor and no request text ever reaches a shell.
*/
function buildTodo(entries, byShas, msgFile) {
const out = [];
const messages = [];
let group = null;
const flush = () => {
if (!group) return;
let msg = null;
if (group.squashed.length) {
msg =
group.squashMessage ??
[group.message ?? byShas.get(group.sha).message, ...group.squashed].join("\n\n");
} else if (group.message != null) msg = group.message;
if (msg != null) {
const file = msgFile(messages.length);
messages.push({ file, text: `${msg.trim()}\n` });
out.push(`exec git commit --amend --allow-empty -q -F ${shq(file)}`);
}
group = null;
};
for (const e of entries) {
if (e.action === "drop") {
out.push(`drop ${e.sha}`);
continue;
}
if (e.action === "pick" || e.action === "reword") {
flush();
out.push(`pick ${e.sha}`);
group = { sha: e.sha, message: e.action === "reword" ? e.message : null, squashed: [] };
continue;
}
// squash / fixup fold into the group before them.
out.push(`fixup ${e.sha}`);
if (e.action === "squash") {
group.squashed.push(byShas.get(e.sha).message);
if (e.message) group.squashMessage = e.message;
}
}
flush();
return { todo: `${out.join("\n")}\n`, messages };
}
/**
* `rebase-interactive {base?, head, todo: [{sha, action, message?}], force?,
* confirm: true}`: the list UI's plan, run without an editor.
*/
async function rebaseInteractive(dir, body) {
if (body.confirm !== true) throw bad("ECONFIRM", "rebase-interactive requires confirm: true");
await assertRepoRoot(dir, "an interactive rebase");
await assertNoOperation(dir);
if (await ops().isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
await assertHead(dir, body.head);
const base = await interactiveBase(dir, typeof body.base === "string" ? body.base : "");
const range = [`${base}..HEAD`];
if ((await lines(dir, ["rev-list", "--merges", ...range])).length) {
throw bad("EMERGECOMMIT", "the range holds a merge commit: rewrite it in a terminal");
}
const commits = await commitList(dir, range);
if (!commits.length) throw bad("ENOCHANGES", "no commit to rewrite");
const todo = Array.isArray(body.todo) ? body.todo : null;
if (!todo || todo.length > MAX_TODO) throw bad("EBADTODO", "todo must list the commits");
const byShas = new Map(commits.map((c) => [c.sha, c]));
const seen = new Set();
for (const e of todo) {
if (!e || typeof e.sha !== "string" || !byShas.has(e.sha) || seen.has(e.sha)) {
throw bad("EBADTODO", "the list no longer matches the branch: refresh and retry");
}
seen.add(e.sha);
if (!TODO_ACTIONS.has(e.action)) throw bad("EBADTODO", `unknown action ${e.action}`);
if (e.message != null) {
const { MAX_MESSAGE_LEN } = ops();
if (typeof e.message !== "string" || e.message.length > MAX_MESSAGE_LEN) {
throw bad("EBADMESSAGE", "a commit message must be a string of at most 4000 characters");
}
e.message = e.message.trim() || null;
}
if (e.action === "reword" && !e.message) throw bad("EBADMESSAGE", "reword needs a message");
}
if (seen.size !== commits.length) {
throw bad("EBADTODO", "the list no longer matches the branch: refresh and retry");
}
const kept = todo.filter((e) => e.action !== "drop");
if (!kept.length) throw bad("EBADTODO", "every commit is dropped: use Reset instead");
if (kept[0].action === "squash" || kept[0].action === "fixup") {
throw bad("EBADTODO", "the first commit kept has nothing before it to fold into");
}
const unchanged =
todo.every((e) => e.action === "pick") && todo.every((e, i) => e.sha === commits[i].sha);
if (unchanged) throw bad("ENOCHANGES", "the list changes nothing");
const pushed = commits.filter((c) => c.pushed).length;
if (pushed && body.force !== true) {
throw bad(
"EPUSHED",
`${pushed} of these ${commits.length} commits are already pushed: rewriting them needs a force-push`,
{ pushed, total: commits.length }
);
}
const gd = await gitDir(dir);
await cleanScratch(dir);
const todoFile = path.join(gd, `${SCRATCH_PREFIX}todo`);
const built = buildTodo(todo, byShas, (i) => path.join(gd, `${SCRATCH_PREFIX}msg-${i}`));
writeScratch(todoFile, built.todo);
for (const m of built.messages) writeScratch(m.file, m.text);
const before = await head(dir);
return runRebase(
dir,
"rebase-interactive",
["rebase", "-i", base],
{ GIT_SEQUENCE_EDITOR: `${shq(cpPath())} -- ${shq(todoFile)}` },
{ before, needsForcePush: pushed > 0 }
);
}
// ───────────────────────── reset / undo ─────────────────────────
/** `{related, dropped, added}` moving HEAD to `target`. */
async function resetFacts(dir, target) {
const isAnc = (a, b) =>
git(dir, ["merge-base", "--is-ancestor", a, b]).then(
() => true,
() => false
);
const related = (await isAnc(target, "HEAD")) || (await isAnc("HEAD", target));
const { all: dropped, pushed } = await pushedOf(dir, [`${target}..HEAD`]);
const added = await lines(dir, ["rev-list", `HEAD..${target}`]);
return { related, dropped, pushed, added };
}
/**
* `reset {target, mode, head, confirm: true, force?, confirmBranch?}` — modes
* soft / mixed (working tree untouched), keep (git refuses to overwrite a
* local change), hard (clean tree only, branch name typed back).
*/
async function resetTo(dir, body) {
if (body.confirm !== true) throw bad("ECONFIRM", "reset requires confirm: true");
const mode = body.mode;
if (!["soft", "mixed", "keep", "hard"].includes(mode)) {
throw bad("EBADMODE", "mode must be soft, mixed, keep or hard");
}
await assertRepoRoot(dir, "a reset");
await assertNoOperation(dir);
await assertHead(dir, body.head);
const target = await ops().commitSha(dir, body.target);
const facts = await resetFacts(dir, target);
if (!facts.related) {
throw bad("ENOTRELATED", "the target is neither an ancestor nor a descendant of HEAD");
}
if (facts.pushed.length && body.force !== true) {
throw bad(
"EPUSHED",
`${facts.pushed.length} of the commits this drops are already pushed: a force-push will be needed`,
{ pushed: facts.pushed.length, total: facts.dropped.length }
);
}
if (mode === "hard") {
if (await ops().isDirty(dir)) {
throw bad("EDIRTY", "a hard reset runs only on a clean tree; use keep to carry changes");
}
const branch = (await currentBranch(dir)) || "HEAD";
if (body.confirmBranch !== branch) {
throw bad("ECONFIRM", `type the branch name (${branch}) to confirm a hard reset`);
}
}
const before = await head(dir);
const r = await git(dir, ["reset", `--${mode}`, target]);
return done("reset", r, {
before,
after: await head(dir),
mode,
needsForcePush: facts.pushed.length > 0,
});
}
/** `undo-rewrite {to, head, confirm: true}`: `reset --keep <to>` while HEAD is still `head`. */
async function undoRewrite(dir, body) {
if (body.confirm !== true) throw bad("ECONFIRM", "undo-rewrite requires confirm: true");
await assertRepoRoot(dir, "an undo");
await assertNoOperation(dir);
await assertHead(dir, body.head);
const to = await ops().commitSha(dir, body.to);
const before = await head(dir);
// --keep: refuses rather than overwrite a local change git cannot carry.
const r = await git(dir, ["reset", "--keep", to]);
return done("undo-rewrite", r, { before, after: await head(dir) });
}
// ───────────────────────── remote writes ─────────────────────────
function allowProtectedForcePush() {
return process.env.AGENTHUB_GIT_FORCE_PUSH_PROTECTED === "1";
}
/** Where a force push of the current branch goes: `{branch, remote, remoteBranch, remoteSha}`. */
async function pushTarget(dir) {
const branch = await currentBranch(dir);
if (!branch) throw bad("EDETACHED", "HEAD is detached: switch to a branch first");
const remote = await ops().pushRemote(dir);
const cfgRemote = (await lines(dir, ["config", "--get", `branch.${branch}.remote`]))[0];
const merge = (await lines(dir, ["config", "--get", `branch.${branch}.merge`]))[0] || "";
const remoteBranch =
cfgRemote === remote && merge.startsWith("refs/heads/")
? merge.slice("refs/heads/".length)
: branch;
if (!(await isValidBranchName(dir, remoteBranch))) {
throw bad("EBADBRANCH", "the upstream branch name is not valid");
}
const remoteSha = (
await git(dir, ["rev-parse", "-q", "--verify", `refs/remotes/${remote}/${remoteBranch}`]).catch(
() => ({ stdout: "" })
)
).stdout.trim();
return { branch, remote, remoteBranch, remoteSha: remoteSha || null };
}
/** `force-push {expect, confirm: true}`: HEAD with a lease on the sha the UI showed. */
async function forcePush(dir, body) {
if (body.confirm !== true) throw bad("ECONFIRM", "force-push requires confirm: true");
const { PROTECTED, networkGit } = ops();
const t = await pushTarget(dir);
if ((PROTECTED.has(t.branch) || PROTECTED.has(t.remoteBranch)) && !allowProtectedForcePush()) {
throw bad("EPROTECTED", `force-pushing ${t.remoteBranch} is not allowed on this server`);
}
const expect = body.expect;
if (!(expect === "" || (typeof expect === "string" && FULL_SHA.test(expect)))) {
throw bad("EBADSHA", "expect must be the remote's full sha the dialog showed (or empty)");
}
try {
const r = await networkGit(
dir,
[
"push",
"-u",
`--force-with-lease=refs/heads/${t.remoteBranch}:${expect}`,
t.remote,
`HEAD:refs/heads/${t.remoteBranch}`,
],
body
);
return done("force-push", r, { remote: t.remote, branch: t.remoteBranch });
} catch (e) {
if (/stale info|\(fetch first\)/.test(e.git?.stderr || "")) {
throw bad("ESTALE", "the remote branch moved since you looked: fetch, check, and retry");
}
throw e;
}
}
/** `{remote, branch}` of a remote-tracking ref named `origin/x`, looked up. */
async function remoteRef(dir, name) {
if (typeof name !== "string" || !name || name.startsWith("-")) {
throw bad("EBADBRANCH", "branch must be a remote branch like origin/feature");
}
const remotes = (await lines(dir, ["remote"])).sort((a, b) => b.length - a.length);
const remote = remotes.find((r) => name.startsWith(`${r}/`));
const branch = remote ? name.slice(remote.length + 1) : "";
if (!remote || !branch || branch === "HEAD") throw bad("ENOBRANCH", `no remote branch ${name}`);
const exists = await git(dir, ["show-ref", "--verify", "-q", `refs/remotes/${name}`]).then(
() => true,
() => false
);
if (!exists) throw bad("ENOBRANCH", `no remote branch ${name}`);
return { remote, branch };
}
async function remoteDefault(dir, remote) {
const ref = (
await git(dir, ["symbolic-ref", "-q", `refs/remotes/${remote}/HEAD`]).catch(() => ({
stdout: "",
}))
).stdout.trim();
return ref.startsWith(`refs/remotes/${remote}/`)
? ref.slice(`refs/remotes/${remote}/`.length)
: null;
}
/** `remote-branch-delete {branch: "origin/x", confirm: true}`. */
async function deleteRemoteBranch(dir, body) {
if (body.confirm !== true) throw bad("ECONFIRM", "remote-branch-delete requires confirm: true");
const { PROTECTED, networkGit } = ops();
const { remote, branch } = await remoteRef(dir, body.branch);
if (PROTECTED.has(branch)) throw bad("EPROTECTED", `branch ${branch} is protected`);
if ((await remoteDefault(dir, remote)) === branch) {
throw bad("EDEFAULTBRANCH", `${branch} is ${remote}'s default branch`);
}
const r = await networkGit(dir, ["push", remote, "--delete", `refs/heads/${branch}`], body);
return done("remote-branch-delete", r, { remote, branch });
}
async function remoteNames(dir) {
return lines(dir, ["remote"]);
}
async function validRemoteName(dir, name, field = "name") {
if (typeof name !== "string" || !REMOTE_NAME.test(name) || name.endsWith(".lock")) {
throw bad("EBADREMOTE", `${field} must be a remote name (letters, digits, . _ -)`);
}
const ok = await git(dir, ["check-ref-format", `refs/remotes/${name}/x`]).then(
() => true,
() => false
);
if (!ok) throw bad("EBADREMOTE", `${field} is not a valid remote name`);
return name;
}
/** remote-add {name, url} / remote-rename {name, to} / remote-remove {name, confirm}. */
async function remoteAction(dir, action, body) {
const names = await remoteNames(dir);
const name = await validRemoteName(dir, body.name);
if (action === "remote-add") {
if (names.includes(name)) throw bad("EREMOTEEXISTS", `remote ${name} already exists`);
const url = ops().validateRemoteUrl(body.url);
return done(action, await git(dir, ["remote", "add", "--", name, url]), { remote: name });
}
if (!names.includes(name)) throw bad("EBADREMOTE", `no remote ${name}`);
if (action === "remote-rename") {
const to = await validRemoteName(dir, body.to, "to");
if (names.includes(to)) throw bad("EREMOTEEXISTS", `remote ${to} already exists`);
return done(action, await git(dir, ["remote", "rename", "--", name, to]), { remote: to });
}
if (body.confirm !== true) throw bad("ECONFIRM", "remote-remove requires confirm: true");
return done(action, await git(dir, ["remote", "remove", "--", name]), { remote: name });
}
// ───────────────────────── reads ─────────────────────────
/**
* A URL with no secret in it: an http(s) URL loses its whole userinfo (a token
* is often typed as the user name), any other keeps only the user name
* (`ssh://git@host`).
*/
function redactUrl(url) {
return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)([^@/]*)@/i, (_, s, info) => {
const user = /^https?:/i.test(s) ? "" : info.split(":")[0];
return user ? `${s}${user}@` : s;
});
}
/** The repo's remotes: `{name, url, pushUrl, defaultBranch}`, passwords redacted. */
async function listRemotes(dir) {
const out = [];
for (const name of await remoteNames(dir)) {
const url = (await lines(dir, ["remote", "get-url", "--", name]))[0] || "";
const pushUrl = (await lines(dir, ["remote", "get-url", "--push", "--", name]))[0] || url;
const prefix = `refs/remotes/${name}/`;
const branches = (await lines(dir, ["for-each-ref", "--format=%(refname)%00%(symref)", prefix]))
.map((l) => l.split("\0"))
.filter(([, symref]) => !symref)
.map(([ref]) => ref.slice(prefix.length));
out.push({
name,
url: redactUrl(url),
pushUrl: redactUrl(pushUrl),
defaultBranch: await remoteDefault(dir, name),
branches,
});
}
return out;
}
/**
* What a rewrite would do, for the dialog that asks before it:
* kind=rebase&onto=<branch> — the commits replayed, which are pushed
* kind=interactive[&base=<sha>] — the commits the list may rewrite
* kind=reset&target=<sha> — the commits dropped/added, which are pushed
* kind=push — where a force push goes and the lease sha
* Every answer carries `head` (the sha a write must name) and `branch`.
*/
async function rewritePlan(dir, q) {
const kind = q.kind;
const h = await head(dir);
const branch = (await currentBranch(dir)) || null;
const subdir = Boolean((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim());
const common = {
kind,
head: h,
branch,
subdir,
operation: await operationInProgress(dir),
dirty: await ops().isDirty(dir),
};
if (kind === "rebase") {
const onto = typeof q.onto === "string" ? q.onto.trim() : "";
if (!(await isValidBranchName(dir, onto))) throw bad("EBADBRANCH", "onto must be a branch");
const ref = await branchRef(dir, onto);
if (!ref) throw bad("ENOBRANCH", `no branch ${onto}`);
const commits = await commitList(dir, [`${ref}..HEAD`]);
const behind = (await lines(dir, ["rev-list", `HEAD..${ref}`])).length;
return { ...common, onto, commits, behind, pushed: commits.filter((c) => c.pushed).length };
}
if (kind === "interactive") {
const base = await interactiveBase(dir, typeof q.base === "string" ? q.base : "");
const range = [`${base}..HEAD`];
const merges = (await lines(dir, ["rev-list", "--merges", ...range])).length > 0;
const commits = await commitList(dir, range);
return { ...common, base, merges, commits, pushed: commits.filter((c) => c.pushed).length };
}
if (kind === "reset") {
const target = await ops().commitSha(dir, q.target);
const f = await resetFacts(dir, target);
return {
...common,
target,
related: f.related,
dropped: f.dropped.length,
added: f.added.length,
pushed: f.pushed.length,
};
}
if (kind === "push") {
const t = await pushTarget(dir);
const ahead = t.remoteSha ? (await lines(dir, ["rev-list", `${t.remoteSha}..HEAD`])).length : 0;
const behind = t.remoteSha
? (await lines(dir, ["rev-list", `HEAD..${t.remoteSha}`])).length
: 0;
const prot = PROTECTED_NAMES(t);
return {
...common,
...t,
ahead,
behind,
protected: prot,
allowed: !prot || allowProtectedForcePush(),
};
}
throw bad("EBADMODE", "kind must be rebase, interactive, reset or push");
}
function PROTECTED_NAMES(t) {
const { PROTECTED } = ops();
return PROTECTED.has(t.branch) || PROTECTED.has(t.remoteBranch);
}
/** Run one action of ACTIONS. `open` is the operation git-ops.js already found. */
async function run(dir, action, body = {}) {
if (action === "conflict-save") return require("./git-conflicts").saveConflict(dir, body);
if (action === "rebase") return rebaseOnto(dir, body);
if (action.startsWith("rebase-") && action !== "rebase-interactive") {
return rebaseStep(dir, action, body);
}
if (action === "rebase-interactive") return rebaseInteractive(dir, body);
if (action === "reset") return resetTo(dir, body);
if (action === "undo-rewrite") return undoRewrite(dir, body);
if (action === "force-push") return forcePush(dir, body);
if (action === "remote-branch-delete") return deleteRemoteBranch(dir, body);
return remoteAction(dir, action, body);
}
module.exports = { ACTIONS, STATUS, run, rewritePlan, listRemotes, redactUrl, buildTodo };
+15 -2
View File
@@ -871,8 +871,21 @@ const OPERATION_HEADS = [
["REVERT_HEAD", "revert"],
];
/** The merge, cherry-pick or revert git has open in `dir`, or null. */
/**
* The merge, cherry-pick, revert or rebase git has open in `dir`, or null. A
* rebase is checked first: a stopped rebase may also hold a CHERRY_PICK_HEAD,
* and it is the rebase the dashboard continues or aborts.
*/
async function operationInProgress(dir) {
for (const name of ["rebase-merge", "rebase-apply"]) {
const rel = await git(dir, ["rev-parse", "--git-path", name]).then(
(r) => r.stdout.trim(),
() => ""
);
const abs = rel && path.resolve(dir, rel);
// `rebase-apply/applying` is a `git am`, not a rebase.
if (abs && fs.existsSync(abs) && !fs.existsSync(path.join(abs, "applying"))) return "rebase";
}
for (const [ref, operation] of OPERATION_HEADS) {
const open = await git(dir, ["rev-parse", "-q", "--verify", ref]).then(
() => true,
@@ -884,7 +897,7 @@ async function operationInProgress(dir) {
}
/**
* A merge, cherry-pick or revert in progress (`merging` stays the one flag
* A merge, cherry-pick, revert or rebase in progress (`merging` stays the one flag
* the UI gates on; `operation` names which) and how many files it still holds
* unmerged (repo-wide).
*/
+10 -1
View File
@@ -14,6 +14,8 @@ const ccConfig = require("./openapi-extra/cc-config");
const codexConfig = require("./openapi-extra/codex-config");
const run = require("./openapi-extra/run");
const lanes = require("./openapi-extra/lanes");
// The history reads; its /git/log replaces the shorter entry in lanes.js.
const gitHistory = require("./openapi-extra/git-history");
const push = require("./openapi-extra/push");
const aiTools = require("./openapi-extra/ai-tools");
const misc = require("./openapi-extra/misc");
@@ -25,6 +27,8 @@ const artifacts = require("./openapi-extra/artifacts");
const lanesMore = require("./openapi-extra/lanes-more");
const toolsMore = require("./openapi-extra/tools-more");
const opsMore = require("./openapi-extra/ops-more");
// The git bar's Branches panel (GET /git/refs + the branch & tag writes).
const gitRefs = require("./openapi-extra/git-refs");
// Enriched overrides of already-documented endpoints — same operationId, tags,
// and request/response `$ref` schemas as the base, with added examples and
// richer descriptions. Listed last so they win the merge.
@@ -57,6 +61,7 @@ module.exports = combine(
codexConfig,
run,
lanes,
gitHistory,
push,
aiTools,
misc,
@@ -66,8 +71,12 @@ module.exports = combine(
lanesMore,
toolsMore,
opsMore,
require("./openapi-extra/git-rewrite"),
overrideSessionsAgents,
overrideCore,
overridePricingAlerts,
overrideOps
overrideOps,
gitRefs
);
// The branch & tag writes join the git action entry in place.
gitRefs.extend(module.exports);
+249
View File
@@ -0,0 +1,249 @@
/**
* @file OpenAPI fragment for the Changes panel's history reads
* (server/routes/lane-git-history.js over server/lib/git-history.js): the
* paged, searchable commit graph (`/git/log`, which replaces the shorter
* entry in openapi-extra/lanes.js), commit details, a commit's patch, a
* file's history across renames, blame at a revision, a tree at a revision,
* comparing two refs, a cherry-pick/revert plan and a paused one's state.
* Exports `{ tags, schemas, paths }` for server/openapi-extra.js. Every
* /api/lanes path is also served under /api/workspaces.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
const q = (name, description, schema = { type: "string" }, required = false) => ({
name,
in: "query",
...(required ? { required } : {}),
description,
schema,
});
/** The repo selectors every /git read takes. */
const target = [
q("session", "A session's own worktree (`claude --worktree`) instead of the workspace folder."),
q("source", "An extra folder of this workspace (`lane_sources` id)."),
q("repo", "A repo inside the workspace folder, by its relative path."),
];
const READ =
" Read-only and unguarded like every /git read; the repo is resolved from the lane row (or ?session / ?source / ?repo), never from a path in the request. A folder that is not a repo answers 200 {available:false}.";
const errors = (bad, forbidden = false) => ({
400: { description: bad },
...(forbidden
? {
403: {
description:
"ESECRET (`.claude/settings.local.json`, at any rev and depth) or EFOREIGN (S3: the folder resolves into another account's files).",
},
}
: {}),
404: { description: "Lane not found, or ENOSOURCE / ENOREPO / ENOWORKTREE." },
});
const paths = {
"/api/lanes/{id}/git/log": {
get: {
tags: ["Lanes"],
summary: "The lane's commit graph, paged and searchable",
description:
"Newest first in --date-order (no parent before its children), each commit with its parents and ref badges ({kind: local|remote|tag|head, name, current?}) for drawing the graph. limit (default 30, max 500) and skip page it; hasMore says a next page exists. all=1 reads every branch, remote branch and tag (the stash excluded); ref= another existing branch (else 400 ENOBRANCH). Search: q= the message (case-insensitive, literal — never a regex) and, when it is hex, the commit with that SHA prefix first; author= name or e-mail (literal); path= (alias file=) only commits touching that lane-relative path (no `..`, else 400 EBADPATH). Text over 200 characters is 400 EBADQUERY. A walk is killed after 20 s." +
READ,
operationId: "listLaneCommits",
parameters: [
idParam,
q("limit", "Page size.", { type: "integer", default: 30, maximum: 500 }),
q("skip", "Commits to skip (the page's start).", { type: "integer", default: 0 }),
q("all", "1 = every branch, remote branch and tag.", { type: "string", enum: ["1"] }),
q("ref", "Another branch's history."),
q("q", "Message text, or a SHA prefix."),
q("author", "Author name or e-mail."),
q("path", "Only commits touching this path."),
q("file", "Alias of path."),
...target,
],
responses: {
200: {
description:
"{available, commits:[{sha, short, subject, author, email, date, refs, parents, badges}], hasMore, filtered}.",
},
...errors("EBADPATH, ENOBRANCH or EBADQUERY."),
},
},
},
"/api/lanes/{id}/git/commit": {
get: {
tags: ["Lanes"],
summary: "One commit in full (details pane)",
description:
"The whole message, author and committer with dates, parents, the commit's own ref badges, isHead, and the branches and tags that contain it (50 each, containsTruncated past that). Its files and diffs are GET /git/changes?range=commit&sha=." +
READ,
operationId: "readLaneCommit",
parameters: [idParam, q("sha", "A commit (sha or ref name).", undefined, true), ...target],
responses: {
200: {
description:
"{available, sha, short, subject, message, author, email, date, committer, committerEmail, committedAt, parents, badges, isHead, branches, tags, containsTruncated}.",
},
...errors("EBADREV."),
},
},
},
"/api/lanes/{id}/git/patch": {
get: {
tags: ["Lanes"],
summary: "A commit as a patch (Copy patch)",
description:
"`git format-patch -1 --stdout --relative` over the commit's files inside the lane. A secret file's diff is never included: it is named in omitted instead. Capped at 2 MB (truncated). A merge commit has no single patch (400 EMERGECOMMIT)." +
READ,
operationId: "readLaneCommitPatch",
parameters: [idParam, q("sha", "A commit.", undefined, true), ...target],
responses: {
200: { description: "{available, sha, patch, omitted:[paths], truncated}." },
...errors("EBADREV or EMERGECOMMIT."),
},
},
},
"/api/lanes/{id}/git/file-history": {
get: {
tags: ["Lanes"],
summary: "One file's history across renames",
description:
"`git log --follow` for one lane-relative path, newest first: each commit with the path the file had there, its status letter (A/M/R/D…) and, for a rename, from. Paged like /git/log (limit ≤ 500, skip, hasMore). A secret file is 403 ESECRET." +
READ,
operationId: "listLaneFileHistory",
parameters: [
idParam,
q("path", "The file.", undefined, true),
q("limit", "Page size.", { type: "integer", default: 100, maximum: 500 }),
q("skip", "Commits to skip.", { type: "integer", default: 0 }),
...target,
],
responses: {
200: {
description:
"{available, path, commits:[{sha, short, subject, author, date, parents, status, path, from?}], hasMore}.",
},
...errors("EBADPATH.", true),
},
},
},
"/api/lanes/{id}/git/blame-at": {
get: {
tags: ["Lanes"],
summary: "Blame at a revision, with each line's text",
description:
"`git blame --porcelain` of the file at rev (a commit) or rev=work (the file on disk). Each line carries its text and the sha that last changed it (null = not committed yet); commits maps a sha to {short, author, date, summary, previous} — previous is where 'blame before this change' goes. Same guards as /git/file: lexical lane path, 403 ESECRET, 1 MB cap (400 ETOOBIG)." +
READ,
operationId: "blameLaneFileAt",
parameters: [
idParam,
q("path", "The file.", undefined, true),
q("rev", "A commit, or work.", { type: "string", default: "work" }),
...target,
],
responses: {
200: {
description:
"{available, path, rev, commits: {<sha>: {short, author, date, summary, previous?}}, lines: [{sha|null, text}]}.",
},
...errors("EBADPATH, EBADREV, ENOFILE or ETOOBIG.", true),
},
},
},
"/api/lanes/{id}/git/tree-at": {
get: {
tags: ["Lanes"],
summary: "One folder of the tree at a commit (Browse files)",
description:
"`git ls-tree` of path (a lane-relative folder; empty = the lane's own folder) at rev, folders first. A secret file is left out of the list; a file's content is then GET /git/file?rev=<sha> (or /git/raw for an image) under that read's own guards. Capped at 5000 entries (truncated)." +
READ,
operationId: "listLaneTreeAt",
parameters: [
idParam,
q("rev", "A commit.", undefined, true),
q("path", "A folder inside the lane."),
...target,
],
responses: {
200: {
description:
"{available, rev, path, entries:[{path, name, dir, submodule, size}], truncated}.",
},
...errors("EBADPATH or EBADREV."),
},
},
},
"/api/lanes/{id}/git/compare": {
get: {
tags: ["Lanes"],
summary: "Compare two refs",
description:
"a and b are branches, tags or commits (each resolved to a commit, else 400 EBADREV). Without path: onlyA / onlyB — the commits only in a and only in b (`--left-right a...b`, 300 each, commitsTruncated) — and files, the files that differ (1000, filesTruncated): mode=three (default) is `a...b`, what b adds since the two diverged; mode=two is `a b`, the two trees as they stand. With path (and a rename's from): that one file with its hunks (200 KB cap); a secret file comes back as {secret:true, hunks:[]}." +
READ,
operationId: "compareLaneRefs",
parameters: [
idParam,
q("a", "Left ref.", undefined, true),
q("b", "Right ref.", undefined, true),
q("mode", "three (a...b) or two (a b).", { type: "string", enum: ["three", "two"] }),
q("path", "One file's diff."),
q("from", "That file's old name, for a rename."),
...target,
],
responses: {
200: {
description:
"{available, a, b, mode, onlyA, onlyB, commitsTruncated, files, filesTruncated} — or {available, a, b, mode, file} with path.",
},
...errors("EBADREV or EBADPATH."),
},
},
},
"/api/lanes/{id}/git/pick-plan": {
get: {
tags: ["Lanes"],
summary: "What a cherry-pick / revert of several commits would do",
description:
"The confirmation dialog's preview, computed by the same code the action runs: shas (comma-separated, 1–50) resolved and ordered — a cherry-pick oldest → newest, a revert newest → oldest (ancestry first, then date) — plus the current branch, whether the tree is clean, whether the lane is the repo root, and busy (an operation is already open). A merge commit is 400 EMERGECOMMIT, an unknown one 400 EBADSHA." +
READ,
operationId: "planLanePick",
parameters: [
idParam,
q(
"op",
"cherry-pick or revert.",
{ type: "string", enum: ["cherry-pick", "revert"] },
true
),
q("shas", "Comma-separated commit ids.", undefined, true),
...target,
],
responses: {
200: {
description:
"{available, op, commits:[{sha, short, subject, author, date}], branch, clean, repoRoot, busy}.",
},
...errors("EBADACTION, EBADSHA or EMERGECOMMIT."),
},
},
},
"/api/lanes/{id}/git/sequence": {
get: {
tags: ["Lanes"],
summary: "The cherry-pick / revert that is paused, if any",
description:
"For the 'paused at commit X (2 of 5)' banner: operation, current (the commit it stopped at), remaining (still to apply), done (applied since it started), startedFrom (the HEAD it started at) and conflicts (unmerged paths). {active:false} when none is open (a merge is not reported here)." +
READ,
operationId: "readLanePickSequence",
parameters: [idParam, ...target],
responses: {
200: {
description:
"{available, active, operation, current, remaining, done, startedFrom, conflicts}.",
},
...errors("—"),
},
},
},
};
module.exports = { tags: [], schemas: {}, paths };
+50
View File
@@ -0,0 +1,50 @@
/**
* @file OpenAPI fragment for the git bar's Branches panel (server/lib/git-refs.js):
* the `GET /api/lanes/{id}/git/refs` read, plus `extend()`, which adds the
* branch & tag writes to the existing `POST /api/lanes/{id}/git/{action}`
* entry in place (its enum and description) rather than replacing that entry.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { REF_ACTIONS } = require("../lib/git-refs");
const REF_ACTIONS_DOC =
" Branch & tag tools (server/lib/git-refs.js; every ref named in the body is LOOKED UP in refs/heads, refs/remotes or refs/tags — or a hex sha verified as a commit — and git gets the full refname, never the request's text; every write here except branch-create runs only from a repo-root lane (400 ESUBDIR) and never while a merge, cherry-pick, revert or rebase is open (409 EMERGING)): branch-create also takes {from} (a local/remote branch, a tag, or a commit sha; 400 EBADREF, 400 EAMBIGUOUS when a branch and a tag share the name — spell out refs/tags/x) and {switch:true} (clean tree only, 400 EDIRTY); branch-rename {branch, name} (`git branch -m`; refuses main/master/development/develop 400 EPROTECTED, an existing name 400 EBRANCHEXISTS, a name `check-ref-format --branch` rejects 400 EBADBRANCH); branch-upstream {branch, upstream} sets it to a fetched remote branch (`origin/x`, 400 ENOBRANCH otherwise) and {branch} with no upstream unsets it (400 ENOUPSTREAM when none); tag-create {tag, message?, from?} (annotated when a message is given; on HEAD or a looked-up start point; 400 EBADTAG / ETAGEXISTS / ENOCOMMITS); tag-delete {tag, confirm:true} (irreversible: 400 ECONFIRM without it, 400 ENOTAG); tag-push {tag, credentials?} pushes ONE tag `refs/tags/<tag>:refs/tags/<tag>` to origin (else the only remote, 400 ENOREMOTE) — no --tags, no force, so a remote tag at another commit is a 409 in git's words.";
const paths = {
"/api/lanes/{id}/git/refs": {
get: {
tags: ["Lanes"],
summary: "Every branch and tag of the lane's repo, for the Branches panel",
description:
"`local`: each local branch with its upstream, ahead/behind against it (null without one, `gone` when the upstream ref is gone), last commit subject and date, `merged` (into HEAD — what `branch -d` checks), `worktree` (another worktree holding it), `protected` and `current`. `remotes`: the fetched remote-tracking branches grouped by remote (symbolic HEADs left out). `tags`: name, target commit `sha`, and for an annotated tag its `message`, `tagger` and `date`. At most 1000 of each kind (`truncated`). The repo comes from the lane row (or `?session` / `?source` / `?repo`), never a path from the query; a folder that is not a repo answers available:false with HTTP 200. Read-only, no same-origin guard.",
operationId: "listLaneGitRefs",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "session", in: "query", required: false, schema: { type: "string" } },
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
],
responses: {
200: {
description:
"{available, current, head, local[], remotes[{remote, branches[]}], tags[], truncated}.",
},
404: { description: "Lane not found, ENOSOURCE, ENOREPO or ENOWORKTREE." },
},
},
},
};
/** Add the branch & tag writes to the combined spec's git action entry. */
function extend(out) {
const post = out.paths["/api/lanes/{id}/git/{action}"]?.post;
if (!post) return out;
const action = (post.parameters || []).find((p) => p.name === "action");
const list = action?.schema?.enum;
if (Array.isArray(list)) for (const a of REF_ACTIONS) if (!list.includes(a)) list.push(a);
if (!post.description.includes("git-refs.js")) post.description += REF_ACTIONS_DOC;
return out;
}
module.exports = { tags: [], schemas: {}, paths, extend };
+118
View File
@@ -0,0 +1,118 @@
/**
* @file OpenAPI fragment for the better-conflicts and history-rewrite READS
* of a workspace (server/lib/git-conflicts.js, server/lib/git-rewrite.js): one
* conflicted file's three stages for the 3-way merge editor, the reflog, the
* repo's remotes and the plan a rewrite dialog shows before it asks. The
* writes themselves are actions of POST /api/lanes/{id}/git/{action}
* (documented in server/openapi-extra/lanes.js). Exports `{ tags, schemas,
* paths }` for server/openapi-extra.js.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
const target = [
{ name: "session", in: "query", schema: { type: "string" } },
{ name: "source", in: "query", schema: { type: "integer" } },
{ name: "repo", in: "query", schema: { type: "string" } },
];
const paths = {
"/api/lanes/{id}/git/conflict": {
get: {
tags: ["Lanes"],
summary: "One conflicted file's three sides, for the merge editor",
description:
"Only a path `git ls-files -u` lists as unmerged right now (400 EBADPATH otherwise), with the read path's guards (lexical lane guard, 403 ESECRET, 1 MB cap). Each stage is read by its blob id: base (:1), ours / current (:2), theirs / incoming (:3); a side git does not have (added on one side, deleted on the other) is null. `working` is the file on disk with its markers and `sha` its content sha — the `baseSha` the `conflict-save` action must name. `operation` is merge, cherry-pick, revert or rebase (in a rebase, ours is the branch being rebased ONTO and theirs the commit being replayed); `incoming` is that operation's commit. Read-only, no same-origin guard.",
operationId: "readLaneConflict",
parameters: [
idParam,
{ name: "path", in: "query", required: true, schema: { type: "string" } },
...target,
],
responses: {
200: {
description:
"{available, path, operation, branch, incoming:{sha,short,subject}|null, base, ours, theirs, working, sha, usable, binary, tooBig}.",
},
400: { description: "EBADPATH." },
403: { description: "ESECRET or EFOREIGN." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/reflog": {
get: {
tags: ["Lanes"],
summary: "A page of the reflog (read-only)",
description:
"`git log -g` of HEAD (default) or of a local branch that exists (looked up, 400 EBADBRANCH / ENOBRANCH otherwise), newest first, `limit` (default 50, max 200) entries from `skip`. A ref with no reflog is an empty list; a folder that is not a repo answers {available:false}. Read-only, no same-origin guard.",
operationId: "readLaneReflog",
parameters: [
idParam,
{ name: "ref", in: "query", schema: { type: "string", default: "HEAD" } },
{ name: "skip", in: "query", schema: { type: "integer", default: 0 } },
{ name: "limit", in: "query", schema: { type: "integer", default: 50 } },
...target,
],
responses: {
200: {
description:
"{available, ref, more, entries:[{index, selector, sha, short, action, message, time, subject}]} — action/message split git's reflog subject (`commit: fix`), time is unix seconds.",
},
400: { description: "EBADBRANCH or ENOBRANCH." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/remotes": {
get: {
tags: ["Lanes"],
summary: "The repo's remotes",
description:
"Each remote's fetch and push URL and its default branch (`refs/remotes/<name>/HEAD`). An http(s) URL loses its whole userinfo and any other keeps only the user name, so no password or token is ever answered. Read-only, no same-origin guard.",
operationId: "listLaneRemotes",
parameters: [idParam, ...target],
responses: {
200: {
description:
"{available, remotes:[{name, url, pushUrl, defaultBranch, branches}]} — branches are the fetched remote-tracking branches (every one, a local copy or not).",
},
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/rewrite-plan": {
get: {
tags: ["Lanes"],
summary: "What a history rewrite would do, before it asks",
description:
"kind=rebase&onto=<branch> — the commits replayed (oldest first, each with pushed), how far behind the branch is; kind=interactive[&base=<sha>] — the commits an interactive rebase may rewrite (default: those no remote-tracking branch holds), base and whether a merge is in the range; kind=reset&target=<sha> — related (ancestor or descendant), how many commits are dropped/added and how many dropped ones are pushed; kind=push — where a force push goes (branch, remote, remoteBranch), the lease sha (remoteSha, from the last fetch), ahead/behind, protected and allowed. Every answer carries head (the full sha a write must name, else ESTALE), branch, subdir, operation and dirty. Read-only, no same-origin guard.",
operationId: "planLaneRewrite",
parameters: [
idParam,
{
name: "kind",
in: "query",
required: true,
schema: { type: "string", enum: ["rebase", "interactive", "reset", "push"] },
},
{ name: "onto", in: "query", schema: { type: "string" } },
{ name: "base", in: "query", schema: { type: "string" } },
{ name: "target", in: "query", schema: { type: "string" } },
...target,
],
responses: {
200: {
description: "{available, kind, head, branch, subdir, operation, dirty, …kind's facts}.",
},
400: {
description:
"EBADMODE, EBADBRANCH, ENOBRANCH, EBADSHA, ENOTRELATED, ENOPARENT, EDETACHED or ENOREMOTE.",
},
404: { description: "Lane not found." },
},
},
},
};
module.exports = { tags: [], schemas: {}, paths };
File diff suppressed because one or more lines are too long
+91
View File
@@ -0,0 +1,91 @@
/**
* @file The Changes panel's history reads, mounted on the lanes router
* (`/api/lanes/:id/git/...` and its `/api/workspaces` alias): commit details,
* a commit's patch, a file's history across renames, blame at a revision, a
* tree at a revision, a two-ref comparison, a cherry-pick/revert plan and a
* paused one's state. All of it is server/lib/git-history.js.
*
* Reads, so unguarded like every other `/git/*` read — and each resolves its
* repository exactly like them, through the lanes router's `gitTargetOr404`
* (the lane row, `?session=`, `?source=`, `?repo=`), never a path from the
* request. Error mapping matches `/git/file`: a secret or a foreign folder is
* 403, a bad request 400, anything else (not a repo, no commits) a 200
* `{available: false}` — a state, not a fault.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const history = require("../lib/git-history");
const BAD = new Set([
"EBADPATH",
"EBADREV",
"EBADQUERY",
"EBADSHA",
"EBADACTION",
"ENOFILE",
"ETOOBIG",
"ENOBRANCH",
"EMERGECOMMIT",
]);
const str = (v) => (typeof v === "string" ? v : "");
/**
* @param {import("express").Router} router The lanes router.
* @param {Function} gitTargetOr404 Its repo resolver: `{lane, cwd}` or null
* (the 404 already sent).
*/
function registerGitHistory(router, gitTargetOr404) {
const read = (sub, fn) =>
router.get(`/:id/git/${sub}`, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({ available: true, ...(await fn(t.cwd, req.query)) });
} catch (err) {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (BAD.has(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.json({ available: false });
}
});
/** GET /git/commit?sha= — one commit's message, people, parents, refs. */
read("commit", (cwd, q) => history.commitDetails(cwd, str(q.sha)));
/** GET /git/patch?sha= — the commit as `format-patch` text (Copy patch). */
read("patch", (cwd, q) => history.commitPatch(cwd, str(q.sha)));
/** GET /git/file-history?path=&skip=&limit= — `log --follow`. */
read("file-history", (cwd, q) => history.fileHistory(cwd, str(q.path), q));
/** GET /git/blame-at?path=&rev= — blame at a commit (or `work`). */
read("blame-at", (cwd, q) => history.blameAt(cwd, str(q.path), str(q.rev) || "work"));
/** GET /git/tree-at?rev=&path= — one folder of the tree at a commit. */
read("tree-at", (cwd, q) => history.treeAt(cwd, str(q.rev), str(q.path)));
/** GET /git/compare?a=&b=&mode=three|two[&path=&from=] — two refs. */
read("compare", (cwd, q) =>
history.compareRefs(cwd, {
a: str(q.a),
b: str(q.b),
mode: q.mode === "two" ? "two" : "three",
path: str(q.path) || null,
from: str(q.from) || null,
})
);
/** GET /git/pick-plan?op=cherry-pick|revert&shas=a,b — what it would run. */
read("pick-plan", (cwd, q) =>
history.pickPreview(
cwd,
str(q.op),
str(q.shas)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
)
);
/** GET /git/sequence — the cherry-pick/revert that is paused, if any. */
read("sequence", (cwd) => history.sequenceState(cwd));
}
module.exports = { registerGitHistory };
+171 -15
View File
@@ -56,7 +56,6 @@ const {
} = require("../lib/worktree");
const {
collectChanges,
listCommits,
defaultBranch,
listPaths,
listTree,
@@ -65,7 +64,10 @@ const {
blameFile,
readImageAt,
} = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { runGitAction, listShelves, PROTECTED: PROTECTED_BRANCHES } = require("../lib/git-ops");
const gitRefs = require("../lib/git-refs");
const { listHistory } = require("../lib/git-history");
const { registerGitHistory } = require("./lane-git-history");
const { withLaneLock } = require("../lib/lane-lock");
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
const { syncMcp } = require("../lib/lane-mcp");
@@ -1660,23 +1662,22 @@ router.get("/:id/git/changes", async (req, res) => {
});
/**
* The lane's recent commits, for the history list. `?file=` narrows it to the
* commits touching one path — the "history of this file" the single-file view
* offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
* The lane's commits, for the history graph (git-history.js `listHistory`):
* `?limit=` (≤ 500, default 30) and `?skip=` page it, `?all=1` reads every
* branch, remote branch and tag, `?ref=` another branch, and `?q=` (message,
* or a SHA prefix), `?author=` and `?path=` (alias `?file=`) search it. Each
* commit carries its parents and ref badges; `hasMore` says a next page
* exists. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
*/
router.get("/:id/git/log", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const commits = await listCommits(cwd, {
limit: req.query.limit,
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
ref: typeof req.query.ref === "string" && req.query.ref ? req.query.ref : null,
});
res.json({ available: true, commits });
const page = await listHistory(cwd, { ...req.query, limit: req.query.limit ?? 30 });
res.json({ available: true, ...page });
} catch (err) {
if (err.code === "EBADPATH" || err.code === "ENOBRANCH") {
if (["EBADPATH", "ENOBRANCH", "EBADQUERY"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.json({ available: false, commits: [] });
@@ -1736,6 +1737,10 @@ router.get("/:id/git/blame", async (req, res) => {
}
});
// The history reads (commit details, patch, file history, blame at a rev,
// tree at a rev, compare, pick plan, a paused pick): lane-git-history.js.
registerGitHistory(router, gitTargetOr404);
/**
* One image's bytes (`?path=&rev=work|HEAD|<sha>`) for the viewer's `<img>`:
* `readImageAt`'s guards (the file read's, plus an image-extension allowlist
@@ -2186,6 +2191,78 @@ router.get("/:id/git/tree", async (req, res) => {
* lane that does not exist yet); this one resolves the repo from the lane row,
* so no path ever comes from the request.
*/
/**
* Conflicts, reflog, remotes and rewrite plans — the reads behind the 3-way
* merge editor and the rewrite dialogs (server/lib/git-conflicts.js,
* server/lib/git-rewrite.js). Reads, so unguarded like `/git/file`; every
* rule about what they may read lives in those modules.
*/
const gitReadError = (res, err) => {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
const status =
{
EBADPATH: 400,
EBADBRANCH: 400,
ENOBRANCH: 400,
EBADSHA: 400,
EBADMODE: 400,
ENOTRELATED: 400,
EDETACHED: 400,
ENOPARENT: 400,
ENOREMOTE: 400,
}[err.code] ?? 500;
res.status(status).json({ error: { code: gitErrorCode(err), message: err.message } });
};
router.get("/:id/git/conflict", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
const path = typeof req.query.path === "string" ? req.query.path : "";
res.json({
available: true,
...(await require("../lib/git-conflicts").readConflict(t.cwd, path)),
});
} catch (err) {
gitReadError(res, err);
}
});
router.get("/:id/git/reflog", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({
available: true,
...(await require("../lib/git-conflicts").readReflog(t.cwd, req.query)),
});
} catch (err) {
if (["EBADBRANCH", "ENOBRANCH"].includes(err.code)) return gitReadError(res, err);
res.json({ available: false, entries: [], more: false });
}
});
router.get("/:id/git/remotes", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({ available: true, remotes: await require("../lib/git-rewrite").listRemotes(t.cwd) });
} catch {
res.json({ available: false, remotes: [] });
}
});
router.get("/:id/git/rewrite-plan", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({
available: true,
...(await require("../lib/git-rewrite").rewritePlan(t.cwd, req.query)),
});
} catch (err) {
gitReadError(res, err);
}
});
router.get("/:id/git/branches", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
@@ -2200,6 +2277,34 @@ router.get("/:id/git/branches", async (req, res) => {
}
});
/**
* Every branch (local with upstream + ahead/behind + why a delete would be
* refused, remote grouped by remote) and tag of the lane's repo, for the git
* bar's Branches panel (server/lib/git-refs.js). Like `/git/branches`, the
* repo comes from the lane row (or its ?session/?source/?repo target), never
* a path from the request; a cwd that is not a repo answers available:false.
*/
router.get("/:id/git/refs", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({
available: true,
...(await gitRefs.listRefs(t.cwd, { protectedNames: PROTECTED_BRANCHES })),
});
} catch {
res.json({
available: false,
current: null,
head: null,
local: [],
remotes: [],
tags: [],
truncated: false,
});
}
});
/**
* The git WRITES the dashboard offers — the list is `ACTIONS` in
* server/lib/git-ops.js (sync, stage/discard, commit/amend/undo-commit,
@@ -2209,7 +2314,8 @@ router.get("/:id/git/branches", async (req, res) => {
* it) so a push and a commit fired from two tabs or two lanes cannot
* interleave on one index, and thin — every
* rule about what each action may do lives in server/lib/git-ops.js, not here.
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
* `git pull` is --ff-only and `switch` refuses a dirty tree unless told to
* carry or shelve the changes, by design there.
*/
/**
* A commit message suggested by the user's `claude` CLI for what is STAGED
@@ -2233,6 +2339,46 @@ router.post("/:id/git/commit-message", sameOriginGuard, async (req, res) => {
}
});
/**
* The workspace's shelves (git-ops.js `listShelves`): the stash entries
* AgentHub made, with their files, newest first. A read, unguarded like the
* other git reads; the repo comes from the lane row (or ?session/?source/
* ?repo), never from a path in the request.
*/
router.get("/:id/git/shelves", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({ available: true, shelves: await listShelves(t.cwd) });
} catch {
res.json({ available: false, shelves: [] });
}
});
/**
* Whether an agent of this workspace is WORKING right now — what makes a
* shelve, a restore or a switch that moves uncommitted changes ask for
* `confirm: true`. A lane no dashboard pane holds mirrors its hooks into
* `status` (running between a prompt and its Stop); a lane a pane holds
* stays `running` while the pane lives, idle prompt included, so there the
* question is whether one of its own sessions has a main agent working.
*/
const agentWorkingStmt = db.prepare(
`SELECT 1 FROM agents a JOIN sessions s ON s.id = a.session_id
WHERE a.type = 'main' AND a.status = 'working' AND s.status = 'active'
AND (s.lane_id = ? OR s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?))
LIMIT 1`
);
function agentWorking(lane) {
if (lane.status !== "running") return false;
if (!lane.run_id) return true;
try {
return !!agentWorkingStmt.get(lane.id, lane.id);
} catch {
return true;
}
}
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
@@ -2247,7 +2393,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
.then((r) => r.stdout.trim())
.catch(() => fs.promises.realpath(cwd).catch(() => cwd));
const result = await withLaneLock(`git:${gitDir}`, () =>
runGitAction(cwd, req.params.action, req.body || {})
runGitAction(cwd, req.params.action, req.body || {}, { agentWorking: agentWorking(lane) })
);
res.json(result);
} catch (err) {
@@ -2280,12 +2426,15 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EMERGECOMMIT",
"ENOPARENT",
"ETRACKED",
"EBADCHANGES",
];
// A numeric code is git's own exit status (execFile reports one that way):
// git ran and refused — a rejected push, a failed credential, a diverged
// pull. That is the repository's state answering a valid request, not a
// server fault, so it is a 409 under the string code the client expects.
const exited = typeof err.code === "number";
// The branch & tag tools' own 400s (server/lib/git-refs.js).
badRequest.push(...gitRefs.BAD_REQUEST_CODES);
// EHUNKGONE is the same kind of answer: the tree moved under the click.
const status = badRequest.includes(err.code)
? 400
@@ -2300,6 +2449,10 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EEMPTY",
"ESTALE",
"EMEMBERGONE",
"ECARRYCONFLICT",
"EAGENTRUNNING",
"ENOSHELF",
"ESHELVED",
].includes(err.code)
? 409
: // Not the request's fault and not a server bug: the host lacks git.
@@ -2311,13 +2464,16 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
// message rather than riding a side field the client would have to know
// about — `message` is the one thing every caller already reads.
const detail = err.git ? (err.git.stderr || "").trim() : "";
res.status(status).json({
// git-rewrite.js's own codes (EBADTODO, ENOREBASE, …) answer its status.
res.status(require("../lib/git-rewrite").STATUS[err.code] ?? status).json({
error: {
code: gitErrorCode(err),
message: detail ? `${err.message}\n${detail}` : err.message,
// EGITAUTH: which host wants a login, for the git bar's form. Never
// a secret — just protocol, host, a username hint and `rejected`.
...(err.auth ? { auth: err.auth } : {}),
// ECARRYCONFLICT: the files a carried switch would overwrite.
...(Array.isArray(err.files) ? { files: err.files } : {}),
},
});
}