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:
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -118,6 +118,7 @@ export const i18nReady = i18n
|
||||
"admin",
|
||||
"onboarding",
|
||||
"artifacts",
|
||||
"gitRefs",
|
||||
],
|
||||
defaultNS: "common",
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user