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

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

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

# Conflicts:
#	CLAUDE.md
#	client/src/components/lanes/LaneGitBar.tsx
#	docs/API.md
#	server/__tests__/lanes-api.test.js
#	server/routes/lanes.js
This commit is contained in:
2026-11-02 11:52:40 +07:00
parent f6eb39eb73
commit 2cc492bb88
20 changed files with 2580 additions and 9 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -32,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>
*/
@@ -55,6 +56,7 @@ 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";
@@ -505,6 +507,19 @@ export function LaneGitBar({
}
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>
@@ -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();
});
});
@@ -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,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;
+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();
}
+1
View File
@@ -118,6 +118,7 @@ export const i18nReady = i18n
"admin",
"onboarding",
"artifacts",
"gitRefs",
],
defaultNS: "common",
interpolation: { escapeValue: false },
+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}}"
}
+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}}"
}
+84 -4
View File
@@ -1241,6 +1241,70 @@ export interface FsTrashItem {
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
export type FsConflict = "fail" | "skip" | "keep" | "replace";
/** A local branch as the Branches panel lists it (`GET …/git/refs`). */
export interface GitLocalBranch {
name: string;
sha: string;
current: boolean;
upstream: string | null;
/** Null without an upstream, or when it is `gone`. */
ahead: number | null;
behind: number | null;
gone: boolean;
subject: string;
date: string | null;
/** Merged into HEAD — what `git branch -d` checks. */
merged: boolean;
/** Another worktree holding it, which `branch -d` refuses. */
worktree: string | null;
protected: boolean;
}
/** A fetched remote-tracking branch (`origin/x`). */
export interface GitRemoteBranch {
name: string;
branch: string;
sha: string;
subject: string;
date: string | null;
}
/** A tag: its target commit and, when annotated, its message and tagger. */
export interface GitTag {
name: string;
sha: string | null;
annotated: boolean;
message: string | null;
tagger: string | null;
date: string | null;
}
export interface GitRefs {
available: boolean;
current: string | null;
head: string | null;
local: GitLocalBranch[];
remotes: { remote: string; branches: GitRemoteBranch[] }[];
tags: GitTag[];
truncated: boolean;
}
export type GitRefAction =
| "branch-create"
| "branch-rename"
| "branch-upstream"
| "branch-delete"
| "merge"
| "tag-create"
| "tag-delete"
| "tag-push";
export interface GitRefBody {
branch?: string;
name?: string;
from?: string;
switch?: boolean;
upstream?: string;
tag?: string;
message?: string;
confirm?: boolean;
}
/** The target for `laneId`: an extra folder's or an inner repo, else `session`'s worktree, else the workspace. */
export function gitTarget(
laneId: number,
@@ -4436,6 +4500,19 @@ export const api = {
current: string | null;
worktrees: LaneWorktree[];
}>(gitUrl(id, "/branches")),
/** GET /api/lanes/:id/git/refs — every branch and tag, for the Branches panel. */
gitRefs: (id: GitTarget) => request<GitRefs>(gitUrl(id, "/refs")),
/**
* POST /api/lanes/:id/git/:action — a branch & tag write (server/lib/git-refs.js):
* `branch-create {branch, from?, switch?}`, `branch-rename {branch, name}`,
* `branch-upstream {branch, upstream?}` (none = unset), `tag-create {tag, message?, from?}`,
* `tag-delete {tag, confirm: true}`, `tag-push {tag}`, plus `merge`/`branch-delete {branch}`.
*/
gitRefAction: (id: GitTarget, action: GitRefAction, body: GitRefBody) =>
request<{ action: string; ok: true; output: string; branch?: string; tag?: string }>(
gitUrl(id, `/${action}`),
{ method: "POST", body: JSON.stringify(body) }
),
/**
* GET /api/lanes/:id/git/shelves — the stash entries AgentHub made
* (shelves), newest first. Other stashes are never listed.
@@ -4620,10 +4697,13 @@ export const api = {
background?: boolean;
}
) =>
request<(LaneSourcesView & { path: string }) | { job: CloneJob }>(`/workspaces/${id}/sources/clone`, {
method: "POST",
body: JSON.stringify(body),
}),
request<(LaneSourcesView & { path: string }) | { job: CloneJob }>(
`/workspaces/${id}/sources/clone`,
{
method: "POST",
body: JSON.stringify(body),
}
),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/workspaces/${id}/sources/folder`, {
+45 -1
View File
@@ -1016,6 +1016,25 @@ comes from the workspace row, never from the query (that is what `GET
not exist yet; in server mode with `AGENTHUB_USERS_ROOT` set, a developer may name
only a repo inside their own folder, else `403 EFOLDER`).
#### Read every branch and tag (the Branches panel)
```http
GET /api/lanes/:id/git/refs
```
`{"available": true, "current": "main", "head": "<sha>", "local": [...], "remotes": [{"remote": "origin", "branches": [...]}], "tags": [...], "truncated": false}`
— what the git bar's **Branches & tags** panel (`client/src/components/lanes/git/BranchManager.tsx`)
lists, read by `server/lib/git-refs.js`. Each `local` entry carries `name`,
`sha`, `current`, `upstream` (or null), `ahead`/`behind` against it (null
without one; `gone: true` when the upstream ref is gone), the last commit's
`subject` and `date`, `merged` (into HEAD — what `git branch -d` checks),
`worktree` (another worktree holding it) and `protected`. `remotes` groups the
fetched remote-tracking branches by remote (symbolic `HEAD`s left out). Each
tag carries `name`, its target commit `sha`, `annotated`, and for an
annotated tag its `message`, `tagger` and `date`. At most 1000 of each kind
(`truncated`). Same target rules as every git read (`?session=`, `?source=`,
`?repo=`); a folder that is not a repo answers `available: false` with 200.
#### A session's own worktree (`?session=`)
Every `/api/lanes/:id/git…` route above and below takes `?session=<id>`: the
@@ -1127,8 +1146,13 @@ cannot collide on `index.lock`.
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
| `switch` | `git switch [-c] <branch>`; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones. A dirty tree needs `changes`: `carry` (a plain `git switch` that brings the changes along) or `shelve` (shelve them, then switch) | `{"branch": "...", "create": false, "changes": "carry\|shelve", "note": "..."}` |
| `branch-create` | `git branch <branch> [<base>]` — or, with `switch: true`, `git switch -c <branch> [<base>]`, the uncommitted changes coming along | `{"branch": "...", "base": "...", "switch": true}` |
| `branch-create` | `git branch <branch> [<base>]` — or, with `switch: true`, `git switch -c <branch> [<base>]`, the uncommitted changes coming along. With `from`, the start point is LOOKED UP (a local or remote branch — a remote one becomes the upstream —, a tag, or a hex sha verified as a commit) and `switch: true` then runs `git switch <branch>` on a clean tree only | `{"branch": "...", "base": "...", "switch": true}` / `{"branch": "...", "from": "v1.2.0", "switch": true}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
| `branch-rename` | `git branch -m <branch> <name>` — the current branch too | `{"branch": "...", "name": "..."}` |
| `branch-upstream` | `git branch --set-upstream-to=refs/remotes/<upstream> <branch>`, or `--unset-upstream` with no `upstream` | `{"branch": "...", "upstream": "origin/x"}` / `{"branch": "..."}` |
| `tag-create` | `git tag <tag> <sha>`, or `git tag -a -m <message> <tag> <sha>` when a message is given — on HEAD or on a looked-up `from` | `{"tag": "v1.2.0", "message": "...", "from": "..."}` |
| `tag-delete` | `git tag -d <tag>` — this machine only | `{"tag": "...", "confirm": true}` |
| `tag-push` | `git push <origin\|the only remote> refs/tags/<tag>:refs/tags/<tag>` — ONE tag, never `--tags`, never forced | `{"tag": "..."}` |
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
| `merge` | `git merge --no-edit -m "Merge branch '<branch>'[ into <current>]" <ref>` — a local or remote-tracking branch that exists; the subject is git's own, never the full refname | `{"branch": "origin/main"}` |
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
@@ -1263,6 +1287,26 @@ cannot collide on `index.lock`.
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
- **Branch & tag tools** (`server/lib/git-refs.js`, the Branches panel): every
ref the body names is LOOKED UP — under `refs/heads`, `refs/remotes` or
`refs/tags`, or a hex sha `rev-parse --verify <x>^{commit}` confirms — and git
is handed the full refname or sha it found, never the request's text
(`400 EBADREF`; `400 EAMBIGUOUS` when a branch and a tag share the name —
spell out `refs/tags/x`). New names go through `git check-ref-format`
(`400 EBADBRANCH` / `400 EBADTAG`; a tag may not start with `-`).
`branch-rename`, `branch-upstream` and the three tag actions are repo-wide,
so they run only from a repo-root workspace (`400 ESUBDIR`) and never while a
merge, cherry-pick, revert or rebase is open (`409 EMERGING`; so is
`branch-create` with `switch: true`). `branch-rename` refuses the protected
names (`400 EPROTECTED`) and an existing name (`400 EBRANCHEXISTS`);
`branch-upstream` takes only a fetched remote branch (`400 ENOBRANCH`) and
unsetting one that has none is `400 ENOUPSTREAM`; `tag-create` refuses an
existing tag (`400 ETAGEXISTS`) and needs a commit (`400 ENOCOMMITS`);
`tag-delete` is irreversible and needs `confirm: true` (`400 ECONFIRM`);
`tag-delete`/`tag-push` of a missing tag is `400 ENOTAG`. `tag-push` goes to
`origin`, else the only remote (`400 ENOREMOTE`), with the same `EGITAUTH`
login flow as `push`; a remote tag already at another commit is a `409 EGIT`
in git's words. Deleting a REMOTE tag is not offered.
- `publish` is the one write whose **destination comes from the request**, so
it only ever gives a remote to a repo that has none: `400 ENOCOMMITS` before
the first commit, `400 EREMOTEEXISTS` once any remote exists (it never
+25 -1
View File
@@ -1595,7 +1595,7 @@ Terminate the running session. If the workspace has no live run, this is a no-op
curl -X POST http://localhost:4820/api/lanes/5/stop
```
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete / publish
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete / publish / branches & tags
The git writes the Changes panel and its action row offer. See
"Acting on a workspace's working copy" above for what each one refuses and why.
@@ -1630,8 +1630,32 @@ curl -X POST http://localhost:4820/api/lanes/5/git/discard \
curl -X POST http://localhost:4820/api/lanes/5/git/publish \
-H "Content-Type: application/json" \
-d '{"url": "git@github.com:owner/repo.git"}'
# Branch & tag tools (the git bar's "Manage branches & tags" panel):
# rename, branch from a tag, track a remote branch, tag HEAD and push the tag
curl -X POST http://localhost:4820/api/lanes/5/git/branch-rename \
-H "Content-Type: application/json" -d '{"branch": "feat/old", "name": "feat/new"}'
curl -X POST http://localhost:4820/api/lanes/5/git/branch-create \
-H "Content-Type: application/json" -d '{"branch": "hotfix/1.2", "from": "v1.2.0"}'
curl -X POST http://localhost:4820/api/lanes/5/git/branch-upstream \
-H "Content-Type: application/json" -d '{"branch": "feat/new", "upstream": "origin/feat/new"}'
curl -X POST http://localhost:4820/api/lanes/5/git/tag-create \
-H "Content-Type: application/json" -d '{"tag": "v1.3.0", "message": "Release 1.3.0"}'
curl -X POST http://localhost:4820/api/lanes/5/git/tag-push \
-H "Content-Type: application/json" -d '{"tag": "v1.3.0"}'
```
The git bar's tags icon opens the **Branches & tags** panel
(`client/src/components/lanes/git/BranchManager.tsx`, read from
`GET /api/lanes/:id/git/refs`): local branches with the current one marked,
upstream ahead/behind and last commit, remote branches grouped by remote, a
search box and arrow-key navigation; picking a branch offers new branch from
it (switch only on a clean tree), rename, set/unset upstream, merge into the
current branch and delete (with the reason when git would refuse: current,
protected, held by another worktree, not merged). Its Tags tab lists tags and
creates (lightweight, or annotated with a message), pushes one tag to origin
and deletes one locally after a confirm.
### clear
Reset the workspace's pipeline progress, stages, notes, and inferred stage. The workspace itself persists; only its recorded history is cleared.
+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" }
);
});
});
+25
View File
@@ -1162,6 +1162,31 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
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",
+7
View File
@@ -79,6 +79,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 });
@@ -115,6 +116,9 @@ const ACTIONS = new Set([
"shelf-drop",
]);
// 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",
@@ -357,6 +361,9 @@ async function networkGit(dir, args, body = {}) {
*/
async function runGitAction(dir, action, body = {}, opts = {}) {
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
if (gitRefs.handles(action, body)) {
return gitRefs.refsAction(dir, action, body, { networkGit, PROTECTED });
}
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.
+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,
};
+6 -1
View File
@@ -25,6 +25,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.
@@ -69,5 +71,8 @@ module.exports = combine(
overrideSessionsAgents,
overrideCore,
overridePricingAlerts,
overrideOps
overrideOps,
gitRefs
);
// The branch & tag writes join the git action entry in place.
gitRefs.extend(module.exports);
+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 };
+32 -1
View File
@@ -65,7 +65,8 @@ const {
blameFile,
readImageAt,
} = require("../lib/git-diff");
const { runGitAction, listShelves } = require("../lib/git-ops");
const { runGitAction, listShelves, PROTECTED: PROTECTED_BRANCHES } = require("../lib/git-ops");
const gitRefs = require("../lib/git-refs");
const { withLaneLock } = require("../lib/lane-lock");
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
const { syncMcp } = require("../lib/lane-mcp");
@@ -2200,6 +2201,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,
@@ -2328,6 +2357,8 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
// 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