feat(lanes): git login from the dashboard, merge and conflict resolution (#45)

Part A — a fetch/pull/push against a remote that needs a login no longer
dies on "terminal prompts disabled": the server answers 409 EGITAUTH with
the host, the git bar asks for a username + password/access token and
resends the same action. AgentHub keeps its OWN credential store
(<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host,
shared by every user of the install, managed from Settings -> Git
credentials. A login is only sent to one of the repo's own remote hosts,
saved only after git accepted it, never returned by any route, and reaches
git through an exclusive credential helper (never argv).

Part B — merge a branch (or the diverged upstream) from the git bar; a
merge that stops on conflicts lists them in the Changes panel with Keep
mine / Take theirs / Mark resolved, then Complete merge or Abort merge
(confirm gate). Clean tree, repo-root lane and an existing ref only;
pull/switch/hunk actions are blocked mid-merge; staging a conflicted file
with markers left is refused. Rebase/reset/stash stay out.
This commit is contained in:
2026-09-25 10:44:40 +07:00
parent 605ca9d65e
commit f514602697
30 changed files with 2005 additions and 59 deletions
+3 -2
View File
File diff suppressed because one or more lines are too long
@@ -21,6 +21,11 @@
* `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here
* before the server is told `confirm: true` — it is the one action in this
* panel git cannot undo.
*
* During a merge (issue #45) a banner offers **Complete merge** (only once no
* file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and
* conflicted files get their own group with Keep mine / Take theirs / Mark
* resolved instead of stage — staging one blind would mark it resolved.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -32,9 +37,12 @@ import {
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
Check,
Columns2,
FileText,
FoldVertical,
ArrowLeftToLine,
ArrowRightToLine,
Folder,
Minus,
Pencil,
@@ -298,6 +306,46 @@ function FileActions({
);
}
type Resolve = (path: string, side: "ours" | "theirs" | "done") => void;
/** A conflicted file's three ways out of the conflict. */
function ConflictActions({
f,
busy,
onResolve,
}: {
f: LaneChangeFile;
busy: boolean;
onResolve: Resolve;
}) {
const { t } = useTranslation(["lanes"]);
return (
<span className="flex shrink-0 items-center gap-0.5">
<IconButton
label={t("changes.takeOurs")}
disabled={busy}
onClick={() => onResolve(f.path, "ours")}
>
<ArrowLeftToLine className="h-3 w-3" />
</IconButton>
<IconButton
label={t("changes.takeTheirs")}
disabled={busy}
onClick={() => onResolve(f.path, "theirs")}
>
<ArrowRightToLine className="h-3 w-3" />
</IconButton>
<IconButton
label={t("changes.markResolved")}
disabled={busy}
onClick={() => onResolve(f.path, "done")}
>
<Check className="h-3 w-3" />
</IconButton>
</span>
);
}
/** One overview row. At module scope on purpose: declared inside the panel it
* was a new component type every render, so each poll and each keystroke in
* the commit box remounted every row (focus dropped to <body>). */
@@ -309,9 +357,11 @@ function ChangeRow({
onOpen,
onAct,
onArm,
onResolve,
}: {
f: LaneChangeFile;
range: LaneChangeRange;
onResolve: Resolve;
busy: boolean;
/** This row's discard has been clicked once and awaits the second click. */
armed: boolean;
@@ -346,9 +396,12 @@ function ChangeRow({
)}
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</button>
{range === "work" && (
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
)}
{range === "work" &&
(f.conflicted ? (
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
) : (
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
))}
</li>
);
}
@@ -409,6 +462,7 @@ export function LaneChangesPanel({
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
const [armedAbort, setArmedAbort] = useState(false);
// The hunk whose Undo was clicked once: `${path}\n${header}`.
const [armedHunk, setArmedHunk] = useState<string | null>(null);
// Files folded shut in review mode. A change-set is read file by file, and
@@ -694,6 +748,28 @@ export function LaneChangesPanel({
}
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
const mergeAct = async (
action: "resolve" | "merge-commit" | "merge-abort",
body: Parameters<typeof api.lanes.gitAction>[2] = {}
) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, body);
if (laneNow.current !== lane) return;
setArmedAbort(false);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
// One hunk, matched by its `@@` line against a fresh diff at the context
// this diff was read with — so the server never applies a stale patch.
const actHunk = async (action: "stage-hunk" | "discard-hunk", path: string, hunk: string) => {
@@ -805,9 +881,11 @@ export function LaneChangesPanel({
const files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0;
const stagedCount = range === "work" ? files.filter((f) => f.staged && !f.conflicted).length : 0;
// What "Stage all" sends: every listed file with a change the index lacks.
const toStage = range === "work" ? files.filter((f) => f.untracked || f.unstaged) : [];
// Never a conflicted one — staging it would mark it resolved.
const toStage =
range === "work" ? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted) : [];
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
// The armed path left the list (committed, reverted, lane switched): disarm,
@@ -1079,6 +1157,38 @@ export function LaneChangesPanel({
</p>
)}
{git?.available && git.merging && (
<div
data-testid="lane-merge-banner"
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
>
<span className="min-w-0 flex-1">
{git.conflicts
? t("changes.mergeBanner", { count: git.conflicts })
: t("changes.mergeReady")}
</span>
<button
type="button"
disabled={busy || !!git.conflicts}
onClick={() => void mergeAct("merge-commit")}
className="shrink-0 rounded border border-accent/40 bg-accent/15 px-1.5 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("changes.mergeCommit")}
</button>
<button
type="button"
disabled={busy}
// Two clicks, like discard: aborting drops every resolution made.
onClick={() =>
armedAbort ? void mergeAct("merge-abort", { confirm: true }) : setArmedAbort(true)
}
className="shrink-0 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:opacity-40"
>
{armedAbort ? t("changes.mergeAbortConfirm") : t("changes.mergeAbort")}
</button>
</div>
)}
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
bar sits at the bottom of the visible box instead of at the bottom of
a 2000-line container nobody has scrolled to yet. Every other mode is
@@ -1207,16 +1317,17 @@ export function LaneChangesPanel({
<div data-testid="lane-changes-overview">
{(range === "work"
? [
{ key: "conflicts", rows: files.filter((f) => f.conflicted) },
{
key: "staged",
rows: files
.filter((f) => f.staged)
.filter((f) => f.staged && !f.conflicted)
.map((f) => ({ ...f, unstaged: false, untracked: false })),
},
{
key: "unstaged",
rows: files
.filter((f) => f.unstaged || f.untracked)
.filter((f) => (f.unstaged || f.untracked) && !f.conflicted)
.map((f) => ({ ...f, staged: false })),
},
]
@@ -1224,7 +1335,11 @@ export function LaneChangesPanel({
).map(
(g) =>
g.rows.length > 0 && (
<div key={g.key} className="mb-1.5">
<div
key={g.key}
className="mb-1.5"
data-testid={g.key === "conflicts" ? "lane-changes-conflicts" : undefined}
>
{g.key !== "all" && (
<div className="flex items-center gap-1.5 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-fg-secondary">
{t(`changes.${g.key}Group`)}
@@ -1244,6 +1359,7 @@ export function LaneChangesPanel({
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
onResolve={resolve}
/>
))}
</ul>
+150 -12
View File
@@ -1,7 +1,9 @@
/**
* @file The lane's git action row: ahead/behind against the upstream, and the
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
* fetch, pull, push, switch branch, and create/delete a branch. Committing is
* fetch, pull, push, switch branch, create/delete a branch, and start a merge
* (the picked branch, or the upstream once diverged; resolving it is the
* Changes panel's job). Committing is
* NOT here: it is the Changes panel's staged-only commit box, beside the
* per-file stage/unstage/discard it commits — one way to commit, and it only
* ever takes what the user staged.
@@ -12,17 +14,32 @@
* Nothing here decides what an action may do; the server does (pull is always
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
* the user more than any sentence this file could write.
* the user more than any sentence this file could write. The one exception
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
* in, so a remote wanting a login opens a form here and the SAME action is
* resent with the typed credential — the token is never rendered back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, GitBranch, Plus, RefreshCw, Trash2, Upload } from "lucide-react";
import { api } from "../../lib/api";
import {
ArrowDown,
ArrowUp,
GitBranch,
GitMerge,
Plus,
RefreshCw,
Trash2,
Upload,
} from "lucide-react";
import { api, type GitAuthNeeded } from "../../lib/api";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete";
type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge";
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
function Btn({
label,
@@ -85,6 +102,11 @@ export function LaneGitBar({
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
// Set when git needed a login for `action`; the form resends that action.
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(true);
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
// All of the state above describes ONE lane — a branch picked for lane A,
// applied after switching, would move B's HEAD — so it is reset during
@@ -102,6 +124,8 @@ export function LaneGitBar({
setWorktrees([]);
setPicked("");
setNewBranch("");
setLogin(null);
setPassword("");
inFlight.current = false;
}
const laneNow = useRef(laneId);
@@ -109,18 +133,25 @@ export function LaneGitBar({
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
const merging = !!git.merging;
const run = async (action: Action, body?: { branch?: string }) => {
const run = async (action: Action, body?: RunBody) => {
if (inFlight.current) return;
inFlight.current = true;
const lane = laneId;
setBusy(action);
setError(null);
try {
await api.lanes.gitAction(lane, action, body || {});
const res = await api.lanes.gitAction(lane, action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
// A merge that stopped on conflicts still succeeded in starting; the
// Changes panel below lists the files to resolve.
if (res?.conflicts?.length)
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
setLogin(null);
setPassword("");
// The branch list this bar is showing is stale the moment a branch is
// created, deleted or switched to, so it is re-read rather than patched.
if (action === "switch" || action.startsWith("branch-")) {
@@ -136,7 +167,12 @@ export function LaneGitBar({
// rejection text verbatim — hence the <pre> below rather than a line.
// ENOREMOTE is the one refusal with no git text to show: the server's
// sentence is English and says less than a translated one.
if (laneNow.current === lane)
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
setLogin({ action, auth });
setUsername((u) => u || auth.username || "");
setPassword("");
} else if (laneNow.current === lane)
setError(
(err as { code?: string })?.code === "ENOREMOTE"
? t("gitBar.errNoRemote")
@@ -170,6 +206,11 @@ export function LaneGitBar({
await refreshBranches();
};
const merge = (branch: string) => {
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
void run("merge", { branch });
};
const heldBy = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
@@ -211,6 +252,18 @@ export function LaneGitBar({
)
)}
{/* Diverged: a --ff-only pull can only refuse, so offer the merge. */}
{git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && (
<Btn
label={t("gitBar.mergeUpstream", { upstream: git.upstream })}
icon={<GitMerge className="h-3 w-3" />}
busy={busy === "merge"}
disabled={!!busy || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
onClick={() => merge(git.upstream as string)}
/>
)}
<div className="ml-auto flex shrink-0 items-center">
<Btn
compact
@@ -226,8 +279,8 @@ export function LaneGitBar({
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3.5 w-3.5" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pullHint")}
disabled={!git.hasRemote || !!busy || merging}
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
<Btn
@@ -243,10 +296,16 @@ export function LaneGitBar({
compact
label={t("gitBar.switch")}
icon={<GitBranch className="h-3.5 w-3.5" />}
disabled={!!busy}
disabled={!!busy || merging}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
title={
merging
? t("gitBar.merging")
: dirty > 0
? t("gitBar.switchDirty")
: t("gitBar.switchHint")
}
onClick={openSwitch}
/>
</div>
@@ -281,6 +340,14 @@ export function LaneGitBar({
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={() => void run("switch", { branch: picked })}
/>
<Btn
label={t("gitBar.merge")}
icon={<GitMerge className="h-3 w-3" />}
busy={busy === "merge"}
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
onClick={() => merge(picked)}
/>
{onNewLane && (
<Btn
label={t("gitBar.newLane")}
@@ -339,6 +406,77 @@ export function LaneGitBar({
</div>
)}
{login && (
<form
data-testid="lane-git-login"
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
onSubmit={(e) => {
e.preventDefault();
const { protocol, host } = login.auth;
void run(login.action, {
credentials: { protocol, host, username: username.trim(), password, remember },
});
}}
>
<p className="font-medium text-fg-primary">
{t("gitBar.loginTitle", { host: login.auth.host })}
</p>
{login.auth.rejected && (
<p role="alert" className="text-status-danger">
{t("gitBar.loginRejected")}
</p>
)}
<div className="flex gap-1.5">
<input
aria-label={t("gitBar.loginUser")}
placeholder={t("gitBar.loginUser")}
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<input
type="password"
aria-label={t("gitBar.loginPassword")}
placeholder={t("gitBar.loginPassword")}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
<label className="flex items-center gap-1.5 text-fg-secondary">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
{t("gitBar.loginRemember")}
</label>
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
{/* The token crosses the network in the clear on a plain-http LAN address. */}
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
<p className="text-[10px] text-status-warning">{t("gitBar.loginInsecure")}</p>
)}
<div className="flex gap-1.5">
<button
type="submit"
disabled={!username.trim() || !password || !!busy}
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitBar.loginSubmit")}
</button>
<Btn
label={t("gitBar.loginCancel")}
onClick={() => {
setLogin(null);
setPassword("");
}}
/>
</div>
</form>
)}
{error && (
<pre
role="alert"
@@ -1245,3 +1245,82 @@ describe("LaneChangesPanel", () => {
expect(screen.getByRole("button", { name: "Expand all folders" })).toBeEnabled();
});
});
describe("a merge in progress (issue #45)", () => {
const merging = (conflicts: number) => ({
available: true as const,
branch: "main",
head: "abc1234",
subject: "main",
dirty: 1,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
merging: true,
conflicts,
});
const conflicted = changes({
files: [
{
path: "a.txt",
status: "UU",
untracked: false,
staged: true,
unstaged: true,
conflicted: true,
added: 3,
deleted: 0,
binary: false,
},
{
path: "b.txt",
status: "A ",
untracked: false,
staged: true,
added: 1,
deleted: 0,
binary: false,
},
],
});
it("lists conflicts on their own, resolves one side, and blocks the commit until none are left", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={3} />);
const group = await screen.findByTestId("lane-changes-conflicts");
expect(within(group).getByText("a.txt")).toBeInTheDocument();
// Not offered as an ordinary stage: that would mark it resolved blind.
expect(screen.getAllByText("a.txt")).toHaveLength(1);
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
path: "a.txt",
side: "theirs",
})
);
});
it("completes the merge once nothing is conflicted, and aborts only on a second click", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(merging(0));
const user = userEvent.setup();
render(<LaneChangesPanel laneId={3} />);
await user.click(await screen.findByRole("button", { name: "Complete merge" }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-commit", {}));
await user.click(screen.getByRole("button", { name: "Abort merge" }));
expect(api.lanes.gitAction).not.toHaveBeenCalledWith(3, "merge-abort", expect.anything());
await user.click(screen.getByRole("button", { name: "Click again to abort the merge" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-abort", { confirm: true })
);
});
});
@@ -255,3 +255,91 @@ describe("LaneGitBar", () => {
);
});
});
describe("git login (issue #45)", () => {
const authErr = (rejected = false) =>
Object.assign(new Error("git.smartgift.io.vn needs a username and password or access token"), {
code: "EGITAUTH",
auth: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", rejected },
});
it("asks for a login when git needs one, then resends the same action with it", async () => {
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(authErr());
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /Push/ }));
const form = await screen.findByTestId("lane-git-login");
expect(form).toHaveTextContent("git.smartgift.io.vn");
expect(screen.getByLabelText("Username")).toHaveValue("vi");
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "push", {
credentials: {
protocol: "https",
host: "git.smartgift.io.vn",
username: "vi",
password: "s3cret",
remember: true,
},
})
);
await waitFor(() => expect(screen.queryByTestId("lane-git-login")).toBeNull());
expect(onChanged).toHaveBeenCalled();
expect(document.body.textContent).not.toContain("s3cret");
});
it("says the login was wrong when the host rejected it, and keeps the form", async () => {
vi.mocked(api.lanes.gitAction)
.mockRejectedValueOnce(authErr())
.mockRejectedValueOnce(authErr(true));
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Fetch/ }));
await user.type(await screen.findByLabelText("Password or access token"), "bad");
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
expect(await screen.findByTestId("lane-git-login")).toHaveTextContent(/rejected/i);
expect(screen.getByLabelText("Password or access token")).toHaveValue("");
});
});
describe("merge (issue #45)", () => {
it("merges the picked branch into the current one after a confirm", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Merge into current" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
);
confirm.mockRestore();
});
it("offers to merge the upstream when the branch has diverged", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "origin/feat/thing" })
);
});
it("disables pull and switch while a merge is in progress", () => {
render(
<LaneGitBar
laneId={3}
git={{ ...facts(), merging: true, conflicts: 1 } as LaneGitFacts}
onChanged={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: /Pull/ })).toBeDisabled();
expect(screen.getByRole("button", { name: /Branch/ })).toBeDisabled();
});
});
+26 -1
View File
@@ -253,5 +253,30 @@
"changes.hunkOf": "{{n}} of {{total}}",
"changes.noEditBinary": "Binary file — not editable here",
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
"changes.noEdit": "Not editable here"
"changes.noEdit": "Not editable here",
"gitBar.loginTitle": "Git needs a login for {{host}}",
"gitBar.loginRejected": "The server rejected that username or token.",
"gitBar.loginUser": "Username",
"gitBar.loginPassword": "Password or access token",
"gitBar.loginRemember": "Remember for everyone using this AgentHub",
"gitBar.loginTokenHint": "On Gitea: Settings → Applications → Generate token, with repository read/write.",
"gitBar.loginInsecure": "This dashboard is on plain http — the token crosses the network unencrypted.",
"gitBar.loginSubmit": "Log in and retry",
"gitBar.loginCancel": "Cancel",
"gitBar.merge": "Merge into current",
"gitBar.mergeHint": "Merge the selected branch into the branch this lane is on",
"gitBar.mergeUpstream": "Merge {{upstream}}",
"gitBar.mergeUpstreamHint": "The branch has diverged from its upstream: merge it in (conflicts are resolved in the Changes panel)",
"gitBar.mergeConfirm": "Merge {{branch}} into {{current}}? Files in this lane change now — if an agent is working in this lane, let it finish first.",
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
"gitBar.merging": "A merge is in progress: finish or abort it first",
"changes.conflictsGroup": "Merge conflicts",
"changes.mergeBanner": "Merge in progress — {{count}} file(s) still conflicted",
"changes.mergeReady": "Merge in progress — every conflict is resolved",
"changes.mergeCommit": "Complete merge",
"changes.mergeAbort": "Abort merge",
"changes.mergeAbortConfirm": "Click again to abort the merge",
"changes.takeOurs": "Keep mine",
"changes.takeTheirs": "Take theirs",
"changes.markResolved": "Mark resolved (after editing out the markers)"
}
+20 -1
View File
@@ -452,7 +452,8 @@
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex",
"aiRouter": "AI Router"
"aiRouter": "AI Router",
"gitCredentials": "Git credentials"
},
"aiRouter": {
"title": "AI Router",
@@ -509,5 +510,23 @@
"lightThemes": "Light themes",
"active": "· in use now",
"default": "Default"
},
"gitCreds": {
"title": "Git credentials",
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push — one per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
"add": "Add login",
"replace": "Replace token",
"remove": "Delete",
"removeConfirm": "Delete this git login? Dashboard fetch/pull/push to that host will ask for a login again.",
"save": "Save",
"cancel": "Cancel",
"fieldHost": "Server URL",
"fieldUser": "Username",
"fieldToken": "Password or access token",
"colHost": "Host",
"colUser": "Username",
"colSaved": "Saved",
"empty": "No git login saved. The git bar asks for one the first time a host needs it.",
"badUrl": "Enter the server as a URL, e.g. https://git.example.com"
}
}
+26 -1
View File
@@ -253,5 +253,30 @@
"changes.hunkOf": "{{n}} / {{total}}",
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
"changes.noEdit": "Không sửa được ở đây"
"changes.noEdit": "Không sửa được ở đây",
"gitBar.loginTitle": "Git cần đăng nhập {{host}}",
"gitBar.loginRejected": "Máy chủ từ chối (rejected) tài khoản hoặc token này.",
"gitBar.loginUser": "Tên đăng nhập",
"gitBar.loginPassword": "Mật khẩu hoặc access token",
"gitBar.loginRemember": "Ghi nhớ cho mọi người dùng AgentHub",
"gitBar.loginTokenHint": "Trên Gitea: Settings → Applications → Generate token, cấp quyền đọc/ghi repository.",
"gitBar.loginInsecure": "Dashboard đang chạy http thường - token sẽ đi qua mạng không mã hoá.",
"gitBar.loginSubmit": "Đăng nhập và thử lại",
"gitBar.loginCancel": "Huỷ",
"gitBar.merge": "Merge vào nhánh hiện tại",
"gitBar.mergeHint": "Merge nhánh đang chọn vào nhánh lane này đang đứng",
"gitBar.mergeUpstream": "Merge {{upstream}}",
"gitBar.mergeUpstreamHint": "Nhánh đã lệch với upstream: merge vào (conflict được giải quyết trong Changes panel)",
"gitBar.mergeConfirm": "Merge {{branch}} vào {{current}}? File trong lane sẽ thay đổi ngay - nếu có agent đang làm việc trong lane này, hãy để nó xong trước.",
"gitBar.mergeConflicts": "Merge dừng ở {{count}} file conflict - hãy giải quyết trong danh sách bên dưới.",
"gitBar.merging": "Đang merge: hãy hoàn tất hoặc huỷ merge trước",
"changes.conflictsGroup": "Conflict khi merge",
"changes.mergeBanner": "Đang merge - còn {{count}} file conflict",
"changes.mergeReady": "Đang merge - đã giải quyết hết conflict",
"changes.mergeCommit": "Hoàn tất merge",
"changes.mergeAbort": "Huỷ merge",
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ merge",
"changes.takeOurs": "Giữ bản của mình",
"changes.takeTheirs": "Lấy bản kia",
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)"
}
+20 -1
View File
@@ -452,7 +452,8 @@
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex",
"aiRouter": "AI Router"
"aiRouter": "AI Router",
"gitCredentials": "Git credentials"
},
"aiRouter": {
"title": "AI Router",
@@ -509,5 +510,23 @@
"lightThemes": "Theme sáng",
"active": "· đang dùng",
"default": "Mặc định"
},
"gitCreds": {
"title": "Git credentials",
"description": "Tài khoản AgentHub dùng cho git khi fetch, pull, push từ dashboard - mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
"add": "Thêm tài khoản",
"replace": "Thay token",
"remove": "Xoá",
"removeConfirm": "Xoá tài khoản git này? Fetch/pull/push tới host đó từ dashboard sẽ hỏi đăng nhập lại.",
"save": "Lưu",
"cancel": "Huỷ",
"fieldHost": "URL máy chủ",
"fieldUser": "Tên đăng nhập",
"fieldToken": "Mật khẩu hoặc access token",
"colHost": "Host",
"colUser": "Tên đăng nhập",
"colSaved": "Đã lưu",
"empty": "Chưa lưu tài khoản git nào. Thanh git sẽ hỏi khi một host cần đăng nhập.",
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com"
}
}
+56 -2
View File
@@ -539,6 +539,8 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
code: body?.error?.code,
status: res.status,
// EGITAUTH from a lane git action: which host wants a login.
auth: body?.error?.auth as GitAuthNeeded | undefined,
});
}
return res.json();
@@ -1921,6 +1923,24 @@ export const api = {
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
/** AgentHub's own git credentials (issue #45), shared by every user of this
* install. No response ever carries a password — only `hasToken`. */
gitCredentials: {
/** GET /api/git-credentials */
list: () => request<{ items: GitCredential[] }>("/git-credentials"),
/** POST /api/git-credentials - save, or replace the one for that host. */
save: (body: GitCredentialInput) =>
request<{ items: GitCredential[] }>("/git-credentials", {
method: "POST",
body: JSON.stringify(body),
}),
/** DELETE /api/git-credentials/:protocol/:host */
remove: (protocol: string, host: string) =>
request<{ items: GitCredential[] }>(
`/git-credentials/${encodeURIComponent(protocol)}/${encodeURIComponent(host)}`,
{ method: "DELETE" }
),
},
aiRouters: {
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
@@ -2535,7 +2555,11 @@ export const api = {
| "stage-hunk"
| "discard-hunk"
| "branch-create"
| "branch-delete",
| "branch-delete"
| "merge"
| "resolve"
| "merge-commit"
| "merge-abort",
body: {
message?: string;
only?: "staged";
@@ -2547,9 +2571,14 @@ export const api = {
hunk?: string;
context?: number;
confirm?: boolean;
/** resolve: which side wins, or `done` for a hand-edited file. */
side?: "ours" | "theirs" | "done";
/** fetch/pull/push after `EGITAUTH`: used for this one command, saved
* only when git accepted it and `remember` is set. */
credentials?: GitCredentialInput & { remember: boolean };
} = {}
) =>
request<{ action: string; ok: true; output: string; branch?: string }>(
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
`/lanes/${id}/git/${action}`,
{ method: "POST", body: JSON.stringify(body) }
),
@@ -2911,6 +2940,31 @@ export interface AiRouterModels {
}
/** One saved AI Router profile, as the API returns it — **never** with a key. */
/** A saved git login, as the server reports it: never the password. */
export interface GitCredential {
protocol: "http" | "https";
host: string;
username: string;
savedAt: string;
hasToken: true;
}
export interface GitCredentialInput {
protocol: string;
host: string;
username: string;
password: string;
}
/** The `auth` block of a 409 `EGITAUTH`: the host git needed a login for. */
export interface GitAuthNeeded {
protocol: "http" | "https";
host: string;
username: string | null;
/** true when a login WAS sent and the host refused it. */
rejected: boolean;
}
export interface AiRouter {
/** Identifier, derived from the label. Stable: a lane stores this. */
name: string;
+4
View File
@@ -2603,6 +2603,10 @@ export type LaneGitFacts =
ahead: number | null;
/** Commits the upstream has that HEAD does not; null without an upstream. */
behind: number | null;
/** A merge is in progress (issue #45). Absent on an older server. */
merging?: boolean;
/** Files the merge still holds unmerged, repo-wide. */
conflicts?: number;
}
| { available: false };
+206
View File
@@ -0,0 +1,206 @@
/**
* @file GitCredentialsConfig.tsx
* @description The **Git credentials** tab of Settings (issue #45) — the
* logins AgentHub itself gives git for a dashboard fetch/pull/push, one per
* host, shared by everyone using this install. Lists them, replaces a token,
* adds one, deletes one. A token is write-only here: the server never returns
* one, so the form starts blank and the list shows only that one is saved.
* The git bar's login form saves into the same store.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, Pencil, Plus, Trash2 } from "lucide-react";
import { api, type GitCredential } from "../lib/api";
const INPUT =
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
export function GitCredentialsConfig() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<GitCredential[]>([]);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
const load = useCallback(async () => {
try {
setItems((await api.gitCredentials.list()).items);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const save = async () => {
if (!form) return;
setError(null);
let url: URL;
try {
url = new URL(form.url.trim());
} catch {
setError(t("gitCreds.badUrl"));
return;
}
try {
const r = await api.gitCredentials.save({
protocol: url.protocol.replace(/:$/, ""),
host: url.host,
username: form.username.trim(),
password: form.token,
});
setItems(r.items);
setForm(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const remove = async (c: GitCredential) => {
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
try {
setItems((await api.gitCredentials.remove(c.protocol, c.host)).items);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<KeyRound className="h-4 w-4 text-fg-muted" />
{t("gitCreds.title")}
</h2>
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
</div>
<button
type="button"
onClick={() => setForm({ url: "", username: "", token: "" })}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
>
<Plus className="h-3.5 w-3.5" />
{t("gitCreds.add")}
</button>
</div>
{error && (
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
{error}
</p>
)}
{form && (
<form
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
onSubmit={(e) => {
e.preventDefault();
void save();
}}
>
<input
aria-label={t("gitCreds.fieldHost")}
placeholder="https://git.example.com"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitCreds.fieldUser")}
placeholder={t("gitCreds.fieldUser")}
autoComplete="off"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className={INPUT}
/>
<input
type="password"
aria-label={t("gitCreds.fieldToken")}
placeholder={t("gitCreds.fieldToken")}
autoComplete="new-password"
value={form.token}
onChange={(e) => setForm({ ...form, token: e.target.value })}
className={INPUT}
/>
<div className="flex gap-2 sm:col-span-3">
<button
type="submit"
disabled={!form.url.trim() || !form.username.trim() || !form.token}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
</button>
<button
type="button"
onClick={() => setForm(null)}
className="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
>
{t("gitCreds.cancel")}
</button>
</div>
</form>
)}
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-left text-xs">
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
<tr>
<th className="px-3 py-2 font-medium">{t("gitCreds.colHost")}</th>
<th className="px-3 py-2 font-medium">{t("gitCreds.colUser")}</th>
<th className="px-3 py-2 font-medium">{t("gitCreds.colSaved")}</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{items.map((c) => (
<tr key={`${c.protocol}://${c.host}`} className="border-t border-border">
<td className="px-3 py-2 font-mono text-[11px] text-fg-primary">{c.host}</td>
<td className="px-3 py-2 text-fg-secondary">{c.username}</td>
<td className="px-3 py-2 text-fg-secondary">
{new Date(c.savedAt).toLocaleString()}
</td>
<td className="px-3 py-2 text-right">
<span className="inline-flex gap-1">
<button
type="button"
aria-label={`${t("gitCreds.replace")} ${c.host}`}
title={t("gitCreds.replace")}
onClick={() =>
setForm({
url: `${c.protocol}://${c.host}`,
username: c.username,
token: "",
})
}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${t("gitCreds.remove")} ${c.host}`}
title={t("gitCreds.remove")}
onClick={() => void remove(c)}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</td>
</tr>
))}
{items.length === 0 && (
<tr className="border-t border-border">
<td colSpan={4} className="px-3 py-4 text-center text-fg-muted">
{t("gitCreds.empty")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+9 -2
View File
@@ -1,17 +1,18 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at, with the active tab kept in the `?tool=` query param.
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Boxes, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
import { Boxes, KeyRound, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
interface ToolTab {
id: string;
@@ -33,6 +34,12 @@ const TOOL_TABS: ToolTab[] = [
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
{
id: "git-credentials",
labelKey: "tools.gitCredentials",
Icon: KeyRound,
Panel: GitCredentialsConfig,
},
];
/**
@@ -0,0 +1,101 @@
/**
* @file Tests for the Git credentials settings tab (issue #45): lists saved
* logins without ever rendering a token, replaces the token for a host, adds
* one from a URL, and deletes only after a confirm.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitCredentialsConfig } from "../GitCredentialsConfig";
import { api } from "../../lib/api";
vi.mock("../../lib/api", () => ({
api: { gitCredentials: { list: vi.fn(), save: vi.fn(), remove: vi.fn() } },
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const saved = {
protocol: "https" as const,
host: "git.smartgift.io.vn",
username: "vi",
savedAt: "2026-09-25T01:00:00.000Z",
hasToken: true as const,
};
beforeEach(() => {
vi.mocked(api.gitCredentials.list)
.mockReset()
.mockResolvedValue({ items: [saved] });
vi.mocked(api.gitCredentials.save)
.mockReset()
.mockResolvedValue({ items: [saved] });
vi.mocked(api.gitCredentials.remove).mockReset().mockResolvedValue({ items: [] });
});
describe("GitCredentialsConfig", () => {
it("lists a saved login and replaces its token", async () => {
const user = userEvent.setup();
render(<GitCredentialsConfig />);
expect(await screen.findByText("git.smartgift.io.vn")).toBeInTheDocument();
expect(screen.getByText("vi")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "gitCreds.replace git.smartgift.io.vn" }));
expect(screen.getByLabelText("gitCreds.fieldHost")).toHaveValue("https://git.smartgift.io.vn");
expect(screen.getByLabelText("gitCreds.fieldToken")).toHaveValue("");
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "newtok");
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
await waitFor(() =>
expect(api.gitCredentials.save).toHaveBeenCalledWith({
protocol: "https",
host: "git.smartgift.io.vn",
username: "vi",
password: "newtok",
})
);
expect(document.body.textContent).not.toContain("newtok");
});
it("adds a login from a URL with a port", async () => {
const user = userEvent.setup();
render(<GitCredentialsConfig />);
await user.click(await screen.findByRole("button", { name: "gitCreds.add" }));
await user.type(
screen.getByLabelText("gitCreds.fieldHost"),
"http://gitea.lan:3000/org/repo.git"
);
await user.type(screen.getByLabelText("gitCreds.fieldUser"), "bot");
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "t");
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
await waitFor(() =>
expect(api.gitCredentials.save).toHaveBeenCalledWith({
protocol: "http",
host: "gitea.lan:3000",
username: "bot",
password: "t",
})
);
});
it("deletes only after a confirm", async () => {
const confirm = vi
.spyOn(window, "confirm")
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
const user = userEvent.setup();
render(<GitCredentialsConfig />);
const del = await screen.findByRole("button", { name: "gitCreds.remove git.smartgift.io.vn" });
await user.click(del);
expect(api.gitCredentials.remove).not.toHaveBeenCalled();
await user.click(del);
await waitFor(() =>
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
);
confirm.mockRestore();
});
});
+54 -2
View File
@@ -585,6 +585,10 @@ cannot collide on `index.lock`.
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
| `merge` | `git merge --no-edit <ref>` — a local or remote-tracking branch that exists | `{"branch": "origin/main"}` |
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
| `merge-commit` | `git commit --no-edit` — concludes the merge | — |
| `merge-abort` | `git merge --abort` | `{"confirm": true}` |
```json
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
@@ -592,8 +596,22 @@ cannot collide on `index.lock`.
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
running agent. There is deliberately no merge, rebase, reset or stash here —
running agent. There is deliberately no rebase, reset or stash here —
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
- **Merge** (issue #45) happens only when asked for by name: on a clean tree
(`400 EDIRTY`), from a lane at the repo root (`400 ESUBDIR` — a merge is
repo-wide), for a branch listed under `refs/heads` or `refs/remotes`
(`400 ENOBRANCH`). A merge that stops on conflicts is still a **200**, with
`conflicts: ["path", …]`; a clean one answers `conflicts: []`. While a merge
is in progress `pull`, `switch`, `merge`, `stage-hunk` and `discard-hunk` are
`409 EMERGING`, and `GET /api/lanes/:id/git` reports `merging: true` plus
`conflicts` (unmerged files, repo-wide). `resolve` accepts only a path git
reports as conflicted (`400 EBADPATH`) and a `side` of `ours`/`theirs`/`done`
(`400 EBADSIDE`); `done` and a plain `stage` of a conflicted file both refuse
while a conflict marker is left in it (`400 EMARKERS`). A side that deleted
the file resolves by removing it. `merge-commit` is `409 ECONFLICTS` while
anything is unmerged, and it and `merge-abort` are `409 ENOMERGE` with no
merge in progress. `merge-abort` needs `confirm: true` (`400 ECONFIRM`).
- `push` pushes `HEAD`, never a branch name from the request, so a lane can
only push the branch it is on.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
@@ -620,7 +638,7 @@ cannot collide on `index.lock`.
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
`ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`); git itself exiting
non-zero (a rejected push, a credential failure, a diverged pull) is
non-zero (a rejected push, a diverged pull) is
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
rejection reads better in git's words. Anything else (git not installed, a
timeout kill) is **500**, still under a string code (`EGIT` when there is
@@ -631,6 +649,40 @@ cannot collide on `index.lock`.
pathspec would commit the working-tree copy (unstaged edits included).
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
remote fails instead of holding the request open.
- **A remote that wants a login** (issue #45) is **409 `EGITAUTH`**, not
`EGIT`: the dashboard's git has no terminal to prompt in. The error carries
`auth: { protocol, host, username, rejected }` — `rejected: true` when a
login WAS sent and refused. Resend the same action with
`credentials: { protocol, host, username, password, remember }`: accepted
only for a host that is one of the repo's own remotes (`400 EBADHOST`
otherwise), used for that one command, and saved to AgentHub's store (below)
only after git succeeded AND `remember` is true. Empty or multi-line values
are `400 EBADCRED`.
#### Git credentials
```
GET /api/git-credentials Saved logins: { items: [{ protocol, host, username, savedAt, hasToken }] }
POST /api/git-credentials Save or replace — Body: { protocol, host, username, password }
DELETE /api/git-credentials/:protocol/:host Delete one (404 ENOTFOUND)
```
AgentHub's OWN git logins, one per `protocol + host`, shared by everyone using
this install; the **Git credentials** tab of Settings
(`/settings?tool=git-credentials`) and the git bar's login form both write
here. Stored in `<CLAUDE_HOME>/agenthub-git-credentials/credentials.json`
(dir `0700`, file `0600`). **No response ever carries a password.** Writes are
same-origin guarded; a bad protocol/host is `400 EBADHOST`, an empty login
`400 EBADCRED`.
A network action reaches git with `-c credential.helper= -c
credential.helper=<AgentHub's helper>` whenever AgentHub holds a login for one
of the repo's remote hosts (or the request carries one) — ONLY AgentHub's
helper, so a stale token in the machine's helper cannot win over the one just
typed, and git's post-success `store` cannot copy a use-once token into the
OS user's helper. Otherwise the command is unchanged and the machine's own
helpers answer as before. The secret never reaches argv: the helper reads the
store file, or a use-once login from its own process env.
#### Read a lane's runtime
+26 -3
View File
@@ -552,12 +552,22 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
| **Create** | `git branch <name>` — never switches | the branch already exists |
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
| **New lane here** | opens **Add lane** prefilled with this repo and the selected branch | — |
| **Merge into current** | `git merge --no-edit <branch>` (after a confirm) | the tree is dirty, the lane is a subdirectory of its repo, or a merge is already in progress |
| **Merge `<upstream>`** | the same, offered when the branch has diverged (ahead AND behind) | as above |
These sit in the panel rather than in the lane card's ⋯ menu deliberately: that menu holds
the rare and dangerous (`reset worktree`), and these are the ordinary moves a
lane needs several times an hour. The boring half of git is the whole list on
purpose — there is no merge, rebase, reset or stash button, because a dashboard
click is the wrong place to start an operation whose recovery needs a terminal.
lane needs several times an hour. There is no rebase, reset or stash button,
because a dashboard click is the wrong place to start an operation whose
recovery needs a terminal. **Merge** is the one exception (issue #45), because
its recovery is a button too: a merge that stops on conflicts puts a banner at
the top of the Changes panel — **Complete merge** (enabled once nothing is
conflicted) and **Abort merge** (two clicks; back to where the branch was) —
and lists the conflicted files in their own **Merge conflicts** group with
**Keep mine**, **Take theirs** and **Mark resolved** (after editing the
`<<<<<<<`/`>>>>>>>` markers out in the file editor; refused while any are
left). Pull and branch switch are disabled until the merge is finished or
aborted.
`pull --ff-only` and the dirty-tree guard on `switch` are what keep either from
inventing a merge commit or a conflict underneath a running agent. **Create**
deliberately does not switch: the usual reason to make a branch from a lane is
@@ -571,6 +581,19 @@ what was staged.
A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)`
says more than any sentence the dashboard could write.
The exception is a remote that wants a **login** (issue #45). The dashboard
runs git without a terminal, so where a shell would prompt, the bar opens a
form instead — "Git needs a login for `<host>`": username, password or access
token (on Gitea: Settings → Applications → Generate token), and **Remember for
everyone using this AgentHub** (on by default) — and resends the same action
with it. A saved login lives in AgentHub's own store, one per host, shared by
every user of the install, and is managed from Settings → **Git credentials**;
it is saved only after git accepted it. While AgentHub holds a login for a
repo's host, that login is the only one git is given for the command — the
machine's own credential helpers are left out, so a stale token there cannot
win. Over plain `http://` on a LAN address the form warns that the token
crosses the network unencrypted. See `docs/API.md` § Git credentials.
### The Changes panel
To the right of the lane detail sits a collapsible **Changes** card (its state
@@ -0,0 +1,57 @@
# Git credentials for dashboard fetch/pull/push (issue #45, part A)
## Problem
The dashboard runs git with no terminal and `GIT_TERMINAL_PROMPT=0`. When no
credential helper on the machine holds a credential for the remote's host, git
wants to prompt, cannot, and fails:
`fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled`.
Reproduced with `git -c credential.helper= push --dry-run`.
## Decisions (approved 2026-09-25)
- AgentHub keeps its OWN credentials, shared by everyone using the AgentHub
install (multi-user login is planned; accounts and settings are shared).
- The dashboard asks for a credential when git needs one, then retries.
- Merge and conflict resolution is part B and gets its own design.
## Design
**Store** — `server/lib/git-credentials.js`, one file
`<CLAUDE_HOME>/agenthub-git-credentials/credentials.json` (dir 0700, file 0600,
written through a temp file), one record per `protocol + host`:
`{protocol, host, username, password, savedAt}`. No route returns `password`;
the list reports `hasToken`. Routes: `GET /api/git-credentials`,
`DELETE /api/git-credentials/:protocol/:host`.
**Delivery** — `server/lib/git-credential-helper.js`, a git credential helper
speaking git's own protocol. For a network action, when AgentHub holds a
credential for one of the repo's remote hosts, or the request carries one, git
runs with `-c credential.helper= -c credential.helper=<ours>`: ONLY our helper.
The machine's helpers are left out on purpose — a stale token there would win
over the one just typed, and git's post-success `store` would copy a
"use once" token into the OS user's own helper. With nothing from AgentHub for
the host, the command is unchanged. The secret never reaches argv: the helper
reads the store file, or `AGENTHUB_GIT_ONCE` in that one git process's env.
**Flow** — an auth failure (`could not read Username|Password`,
`Authentication failed`, `terminal prompts disabled`, `Invalid username or
password`) answers `409 EGITAUTH` with `{protocol, host, username?}`. The git
bar shows a form (username, password/token, "remember for everyone on this
AgentHub", on by default) and resends the action with
`credentials: {protocol, host, username, password, remember}`. The server
accepts it only for a host that is one of the repo's remotes, runs the action,
and saves the credential only after git succeeded and `remember` is set.
**Limits** — one credential per host, so everyone on one AgentHub pushes as
the same account. `fetch --all` over remotes on several hosts: a host with no
AgentHub credential gets no machine helper either while one does. Over plain
HTTP on a LAN the token crosses the network unencrypted; the form says so.
## Testing
Store: save/list/remove, 0600, list never carries the password, bad host
refused. Helper: real `git credential fill` through our helper, stored and
once. Route: a local HTTP remote answering 401 → `EGITAUTH` with the host; a
resend with credentials reaches that server as Basic auth; nothing saved on
failure. Client: the form appears on `EGITAUTH`, resends, never renders a token.
+4 -1
View File
@@ -532,7 +532,10 @@ Request body shape:
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the lane. |
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
an existing absolute git repository. The response lane starts as
@@ -0,0 +1,95 @@
/**
* @file Route tests for git credentials (issue #45): /api/git-credentials
* never returns a password and refuses a cross-origin write, and a lane's
* fetch against a remote that wants a login answers 409 EGITAUTH with the
* host the git bar's login form needs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-route-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.GIT_CONFIG_NOSYSTEM = "1";
process.env.HOME = ROOT;
const { createApp } = require("../index");
let app;
let remote;
let base;
let host;
function request(method, p, body, headers = {}) {
return fetch(`${base}${p}`, {
method,
headers: { "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
}
before(async () => {
remote = http.createServer((_req, res) => {
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
res.end();
});
await new Promise((r) => remote.listen(0, "127.0.0.1", r));
host = `127.0.0.1:${remote.address().port}`;
app = http.createServer(createApp());
await new Promise((r) => app.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${app.address().port}`;
});
after(async () => {
await new Promise((r) => app.close(r));
await new Promise((r) => remote.close(r));
});
describe("/api/git-credentials", () => {
it("saves, lists without the secret, and deletes", async () => {
const saved = await request("POST", "/api/git-credentials", {
protocol: "https",
host: "git.example.test",
username: "vi",
password: "super-secret-token",
});
assert.equal(saved.status, 200);
const list = await request("GET", "/api/git-credentials");
assert.equal(list.body.items[0].hasToken, true);
assert.ok(!JSON.stringify(list.body).includes("super-secret-token"));
const del = await request("DELETE", "/api/git-credentials/https/git.example.test");
assert.deepEqual(del.body.items, []);
});
it("refuses a cross-origin write", async () => {
const r = await request(
"POST",
"/api/git-credentials",
{ protocol: "https", host: "h.test", username: "u", password: "p" },
{ Origin: "https://evil.example" }
);
assert.equal(r.status, 403);
});
});
describe("POST /api/lanes/:id/git/fetch", () => {
it("answers 409 EGITAUTH with the host when the remote wants a login", async () => {
const repo = path.join(ROOT, "repo");
execFileSync("git", ["init", "-q", repo]);
execFileSync("git", ["-C", repo, "remote", "add", "origin", `http://${host}/o/r.git`]);
const lane = await request("POST", "/api/lanes/ensure", { cwd: repo, tool: "claude" });
const r = await request("POST", `/api/lanes/${lane.body.lane.id}/git/fetch`, {});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "EGITAUTH");
assert.equal(r.body.error.auth.host, host);
assert.equal(r.body.error.auth.rejected, false);
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* @file Tests for AgentHub's own git credential store and the credential
* helper that hands it to git: save/list/remove at 0600, a list that never
* carries the secret, auth-failure detection, and a REAL `git credential fill`
* run through the helper — stored and use-once.
* @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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-"));
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
const creds = require("../lib/git-credentials");
function fill(dir, cfg, env = {}) {
// Exactly what git-ops passes, then git's own credential lookup.
const out = execFileSync("git", [...cfg.args, "credential", "fill"], {
cwd: dir,
input: "protocol=https\nhost=git.example.test\n\n",
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...cfg.env, ...env },
encoding: "utf8",
});
return Object.fromEntries(
out
.trim()
.split("\n")
.map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)])
);
}
beforeEach(() => fs.rmSync(path.join(ROOT, ".claude"), { recursive: true, force: true }));
describe("git credential store", () => {
it("saves one record per protocol+host at 0600 and never lists the secret", () => {
creds.save({ protocol: "https", host: "Git.Example.Test", username: "a", password: "t1" });
creds.save({ protocol: "https", host: "git.example.test", username: "b", password: "t2" });
const list = creds.list();
assert.equal(list.length, 1);
assert.equal(list[0].host, "git.example.test");
assert.equal(list[0].username, "b");
assert.equal(list[0].hasToken, true);
assert.ok(!JSON.stringify(list).includes("t2"));
assert.equal(fs.statSync(creds.credentialsFile()).mode & 0o777, 0o600);
assert.equal(creds.remove("https", "git.example.test"), true);
assert.deepEqual(creds.list(), []);
});
it("refuses a host that is not a host", () => {
for (const host of ["", "a/b", "x y", "../etc", "h\nost"]) {
assert.throws(() => creds.save({ protocol: "https", host, username: "u", password: "p" }), {
code: "EBADHOST",
});
}
assert.throws(() => creds.save({ protocol: "ssh", host: "h", username: "u", password: "p" }), {
code: "EBADHOST",
});
});
it("recognises git's auth failures and names the host", () => {
assert.deepEqual(
creds.authFailure(
"fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled"
),
{ protocol: "https", host: "git.smartgift.io.vn" }
);
assert.deepEqual(
creds.authFailure(
"remote: Invalid username or password.\nfatal: Authentication failed for 'http://h.local:3000/o/r.git/'"
),
{ protocol: "http", host: "h.local:3000" }
);
assert.equal(creds.authFailure("! [rejected] main -> main (fetch first)"), null);
});
});
describe("git credential helper, through real git", () => {
const repo = fs.mkdtempSync(path.join(ROOT, "repo-"));
execFileSync("git", ["init", "-q", repo]);
execFileSync("git", ["-C", repo, "remote", "add", "origin", "https://git.example.test/o/r.git"]);
it("answers from the store, and only our helper is asked", async () => {
creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" });
const cfg = await creds.gitConfigFor(repo);
assert.equal(cfg.args[1], "credential.helper=");
const got = fill(repo, cfg);
assert.equal(got.username, "u1");
assert.equal(got.password, "stored");
assert.ok(!cfg.args.join(" ").includes("stored"), "secret must not reach argv");
});
it("prefers a use-once credential, which never touches the store", async () => {
creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" });
const once = { protocol: "https", host: "git.example.test", username: "u2", password: "once" };
const cfg = await creds.gitConfigFor(repo, once);
const got = fill(repo, cfg);
assert.equal(got.password, "once");
assert.equal(creds.list()[0].username, "u1");
assert.ok(!cfg.args.join(" ").includes("once"));
});
it("changes nothing when AgentHub holds nothing for the repo's hosts", async () => {
const cfg = await creds.gitConfigFor(repo);
assert.deepEqual(cfg, { args: [], env: {} });
});
it("refuses a use-once credential for a host the repo does not use", async () => {
await assert.rejects(
creds.gitConfigFor(repo, {
protocol: "https",
host: "evil.test",
username: "u",
password: "p",
}),
{ code: "EBADHOST" }
);
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* @file Issue #45: a dashboard fetch/pull/push against a remote that needs a
* login. A local HTTP "remote" answers 401 to every request and records the
* Authorization header it was sent, so the tests see exactly what git sent:
* nothing → EGITAUTH naming the host; a typed credential → that credential,
* as Basic auth, to that host; saved only when git succeeded AND asked to.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, 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 http = require("node:http");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-ops-auth-"));
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
// The machine's own helpers must not answer for the fake host.
process.env.GIT_CONFIG_NOSYSTEM = "1";
process.env.HOME = ROOT;
const { runGitAction } = require("../lib/git-ops");
const creds = require("../lib/git-credentials");
let server;
let host;
const seen = [];
const LANE = path.join(ROOT, "lane");
before(async () => {
server = http.createServer((req, res) => {
seen.push(req.headers.authorization || null);
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
res.end();
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
host = `127.0.0.1:${server.address().port}`;
execFileSync("git", ["init", "-q", LANE]);
execFileSync("git", ["-C", LANE, "remote", "add", "origin", `http://${host}/o/r.git`]);
});
after(() => new Promise((r) => server.close(r)));
beforeEach(() => {
seen.length = 0;
fs.rmSync(process.env.CLAUDE_HOME, { recursive: true, force: true });
});
describe("git auth on fetch", () => {
it("answers EGITAUTH naming the host when git has no credential", async () => {
await assert.rejects(runGitAction(LANE, "fetch"), (e) => {
assert.equal(e.code, "EGITAUTH");
assert.deepEqual(e.auth, { protocol: "http", host, username: null, rejected: false });
return true;
});
});
it("sends a typed credential as Basic auth, and does not save a failed one", async () => {
await assert.rejects(
runGitAction(LANE, "fetch", {
credentials: { protocol: "http", host, username: "vi", password: "tok", remember: true },
}),
(e) => e.code === "EGITAUTH" && e.auth.rejected === true
);
const basic = `Basic ${Buffer.from("vi:tok").toString("base64")}`;
assert.ok(seen.includes(basic), `expected ${basic} in ${JSON.stringify(seen)}`);
assert.deepEqual(creds.list(), [], "a credential git rejected is never saved");
});
it("uses a stored credential without being asked", async () => {
creds.save({ protocol: "http", host, username: "saved", password: "s3" });
await assert.rejects(runGitAction(LANE, "fetch"), { code: "EGITAUTH" });
assert.ok(seen.includes(`Basic ${Buffer.from("saved:s3").toString("base64")}`));
});
it("refuses a credential for a host that is not this repo's remote", async () => {
await assert.rejects(
runGitAction(LANE, "fetch", {
credentials: { protocol: "https", host: "evil.test", username: "u", password: "p" },
}),
{ code: "EBADHOST" }
);
assert.equal(seen.length, 0);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* @file Issue #45 part B: merge and conflict resolution from the dashboard,
* against a REAL repo whose `main` and `feat` edit the same line. Pins the
* guards (clean tree, repo-root lane, a ref that exists, one merge at a time,
* a path that IS conflicted, markers gone before "resolved", confirm on
* abort) and the happy paths (ours, theirs, hand-edited, commit, abort).
* @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 { gitFacts } = require("../lib/worktree");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-merge-"));
const REPO = path.join(ROOT, "repo");
const g = (...args) => {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
};
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
beforeEach(() => {
fs.rmSync(REPO, { recursive: true, force: true });
fs.mkdirSync(REPO, { recursive: true });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@h");
g("config", "user.name", "t");
fs.mkdirSync(path.join(REPO, "sub"));
write("a.txt", "base\n");
write("sub/s.txt", "s\n");
g("add", "-A");
g("commit", "-qm", "init");
g("switch", "-qc", "feat");
write("a.txt", "theirs\n");
write("b.txt", "only on feat\n");
g("add", "-A");
g("commit", "-qm", "feat");
g("switch", "-q", "main");
write("a.txt", "ours\n");
g("commit", "-qam", "main");
});
const merge = () => runGitAction(REPO, "merge", { branch: "feat" });
describe("merge", () => {
it("reports conflicts instead of failing, and gitFacts shows the merge", async () => {
const r = await merge();
assert.equal(r.ok, true);
assert.deepEqual(r.conflicts, ["a.txt"]);
const facts = await gitFacts(REPO);
assert.equal(facts.merging, true);
assert.equal(facts.conflicts, 1);
});
it("merges cleanly when there is nothing to resolve", async () => {
write("a.txt", "theirs\n");
g("commit", "-qam", "agree");
const r = await merge();
assert.deepEqual(r.conflicts, []);
assert.equal(read("b.txt"), "only on feat\n");
assert.equal((await gitFacts(REPO)).merging, false);
});
it("refuses a dirty tree, an unknown ref, a subdirectory lane and a second merge", async () => {
write("a.txt", "wip\n");
await assert.rejects(merge(), { code: "EDIRTY" });
g("checkout", "--", "a.txt");
await assert.rejects(runGitAction(REPO, "merge", { branch: "nope" }), { code: "ENOBRANCH" });
await assert.rejects(runGitAction(REPO, "merge", { branch: "--help" }), { code: "EBADBRANCH" });
await assert.rejects(runGitAction(path.join(REPO, "sub"), "merge", { branch: "feat" }), {
code: "ESUBDIR",
});
await merge();
await assert.rejects(merge(), { code: "EMERGING" });
await assert.rejects(runGitAction(REPO, "pull"), { code: "EMERGING" });
});
});
describe("resolve", () => {
it("takes our side or theirs, and only for a conflicted path", async () => {
await merge();
await assert.rejects(runGitAction(REPO, "resolve", { path: "b.txt", side: "ours" }), {
code: "EBADPATH",
});
await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "mine" }), {
code: "EBADSIDE",
});
await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" });
assert.equal(read("a.txt"), "theirs\n");
assert.equal((await gitFacts(REPO)).conflicts, 0);
});
it("marks a hand-edited file resolved only once its markers are gone", async () => {
await merge();
await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "done" }), {
code: "EMARKERS",
});
await assert.rejects(runGitAction(REPO, "stage", { paths: ["a.txt"] }), { code: "EMARKERS" });
write("a.txt", "both\n");
await runGitAction(REPO, "resolve", { path: "a.txt", side: "done" });
assert.equal((await gitFacts(REPO)).conflicts, 0);
});
});
describe("finish or abort", () => {
it("commits the merge only when nothing is left unresolved", async () => {
await merge();
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ECONFLICTS" });
await runGitAction(REPO, "resolve", { path: "a.txt", side: "ours" });
await runGitAction(REPO, "merge-commit");
assert.equal((await gitFacts(REPO)).merging, false);
assert.equal(g("rev-list", "--parents", "-n1", "HEAD").trim().split(" ").length, 3);
assert.equal(read("a.txt"), "ours\n");
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ENOMERGE" });
});
it("aborts only with confirm, back to where it started", async () => {
await merge();
await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" });
await runGitAction(REPO, "merge-abort", { confirm: true });
assert.equal((await gitFacts(REPO)).merging, false);
assert.equal(read("a.txt"), "ours\n");
assert.equal(fs.existsSync(path.join(REPO, "b.txt")), false);
});
});
+2
View File
@@ -74,6 +74,7 @@ const updatesRouter = require("./routes/updates");
const ccConfigRouter = require("./routes/cc-config");
const codexConfigRouter = require("./routes/codex-config");
const aiRoutersRouter = require("./routes/ai-routers");
const gitCredentialsRouter = require("./routes/git-credentials");
const runRouter = require("./routes/run");
const alertsRouter = require("./routes/alerts");
const webhooksRouter = require("./routes/webhooks");
@@ -117,6 +118,7 @@ function createApp() {
app.use("/api/cc-config", ccConfigRouter);
app.use("/api/codex-config", codexConfigRouter);
app.use("/api/ai-routers", aiRoutersRouter);
app.use("/api/git-credentials", gitCredentialsRouter);
app.use("/api/run", runRouter);
app.use("/api/lanes", lanesRouter);
app.use("/api/locks", locksRouter);
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* @file The git credential helper AgentHub installs on its own network git
* commands (`-c credential.helper=!node <this>`, see git-credentials.js).
* Speaks git's credential protocol: `get` reads `key=value` lines on stdin
* and answers `username=` / `password=` for a matching protocol + host —
* first from `AGENTHUB_GIT_ONCE` (a credential typed for this one command),
* then from the store file named by `AGENTHUB_GIT_CRED_FILE`. `store` and
* `erase` do nothing: AgentHub saves only what the user asked it to keep, and
* only after git has proved it works. Prints nothing else, ever.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
if (process.argv[2] !== "get") process.exit(0);
let input = "";
process.stdin.on("data", (c) => (input += c));
process.stdin.on("end", () => {
const want = {};
for (const line of input.split("\n")) {
const i = line.indexOf("=");
if (i > 0) want[line.slice(0, i)] = line.slice(i + 1);
}
const protocol = String(want.protocol || "").toLowerCase();
const host = String(want.host || "").toLowerCase();
const matches = (r) => r && r.protocol === protocol && r.host === host && r.password;
let hit = null;
try {
const once = JSON.parse(process.env.AGENTHUB_GIT_ONCE || "null");
if (matches(once)) hit = once;
} catch {
/* malformed: fall through to the store */
}
if (!hit && process.env.AGENTHUB_GIT_CRED_FILE) {
try {
const rows = JSON.parse(fs.readFileSync(process.env.AGENTHUB_GIT_CRED_FILE, "utf8"));
hit = (Array.isArray(rows) ? rows : []).find(matches) || null;
} catch {
/* no store yet */
}
}
// A helper that answers nothing lets git fail with its own auth error,
// which the dashboard turns into the login form.
if (hit) process.stdout.write(`username=${hit.username}\npassword=${hit.password}\n`);
});
+195
View File
@@ -0,0 +1,195 @@
/**
* @file AgentHub's own git credentials — what a dashboard fetch/pull/push
* authenticates with when the machine's git has nothing (issue #45). The
* dashboard runs git with no terminal, so without this a remote needing a
* login fails with "could not read Username … terminal prompts disabled".
*
* One JSON file under `<CLAUDE_HOME>/agenthub-git-credentials/` (dir 0700,
* file 0600, written through a temp file), one record per protocol + host,
* shared by everyone using this AgentHub. Like the AI Router store, **no route
* ever returns a password** — `list` reports `hasToken`.
*
* `gitConfigFor` is how a credential reaches git: `-c` args that make OUR
* helper (git-credential-helper.js) the ONLY one, plus the env it reads. Only
* ours, because a stale token in the machine's helper would win over the one
* just typed, and git's post-success `store` would copy a use-once token into
* the OS user's own helper. The secret itself never reaches argv.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
const PROTOCOLS = new Set(["https", "http"]);
const HELPER = path.join(__dirname, "git-credential-helper.js");
function err(code, message) {
return Object.assign(new Error(message), { code });
}
function credentialsFile() {
return path.join(getClaudeHome(), "agenthub-git-credentials", "credentials.json");
}
/** `{protocol, host}` normalised, or EBADHOST — a host is also a lookup key. */
function key(protocol, host) {
const p = String(protocol || "").toLowerCase();
const h = String(host || "").toLowerCase();
if (!PROTOCOLS.has(p) || !HOST_RE.test(h)) {
throw err("EBADHOST", "protocol must be http(s) and host a plain host[:port]");
}
return { protocol: p, host: h };
}
function readAll() {
try {
const rows = JSON.parse(fs.readFileSync(credentialsFile(), "utf8"));
return Array.isArray(rows) ? rows : [];
} catch {
return [];
}
}
function writeAll(rows) {
const file = credentialsFile();
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.agenthub-tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(rows, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tmp, file);
}
function list() {
return readAll().map(({ protocol, host, username, savedAt }) => ({
protocol,
host,
username,
savedAt,
hasToken: true,
}));
}
function find(protocol, host) {
const k = key(protocol, host);
return readAll().find((r) => r.protocol === k.protocol && r.host === k.host) || null;
}
function save({ protocol, host, username, password }) {
const k = key(protocol, host);
if (typeof username !== "string" || !username.trim() || /[\r\n]/.test(username)) {
throw err("EBADCRED", "username is required");
}
if (typeof password !== "string" || !password || /[\r\n]/.test(password)) {
throw err("EBADCRED", "password or access token is required");
}
const rows = readAll().filter((r) => !(r.protocol === k.protocol && r.host === k.host));
rows.push({ ...k, username: username.trim(), password, savedAt: new Date().toISOString() });
writeAll(rows);
}
function remove(protocol, host) {
const k = key(protocol, host);
const rows = readAll();
const kept = rows.filter((r) => !(r.protocol === k.protocol && r.host === k.host));
if (kept.length === rows.length) return false;
writeAll(kept);
return true;
}
/**
* git's wording for "I needed a login and did not get one (or it was wrong)",
* and the URL it names. null for every other failure.
*/
function authFailure(stderr) {
const text = String(stderr || "");
if (
!/could not read (Username|Password)|Authentication failed|terminal prompts disabled|Invalid username or password/i.test(
text
)
) {
return null;
}
const m = text.match(/'(https?):\/\/(?:[^@'/]*@)?([^/'\s]+)/i);
if (!m) return null;
try {
return key(m[1], m[2]);
} catch {
return null;
}
}
/** The http(s) hosts a repo's remotes point at. */
async function remoteHosts(dir) {
// Required lazily: worktree.js is heavy and only this path needs it.
const { git } = require("./worktree");
const out = await git(dir, ["remote", "-v"]).then(
(r) => r.stdout,
() => ""
);
const hosts = new Map();
// A username in the URL (https://vi@host/…) pre-fills the login form; a
// password in it (user:pass@) is never read back out.
for (const m of out.matchAll(/\t(https?):\/\/(?:([^@\s/:]*)(?::[^@\s/]*)?@)?([^/\s]+)/gi)) {
try {
const k = key(m[1], m[3]);
hosts.set(`${k.protocol}://${k.host}`, {
...k,
username: m[2] ? decodeURIComponent(m[2]) : null,
});
} catch {
/* not a host we can hold a credential for */
}
}
return [...hosts.values()];
}
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
/**
* The `-c` args and env a network git command runs with. `once` is a
* credential from the request, accepted only for one of the repo's own
* remote hosts — a key typed for one server is never sent to another.
*/
async function gitConfigFor(dir, once) {
const hosts = await remoteHosts(dir);
const env = {};
if (once) {
const k = key(once.protocol, once.host);
if (!hosts.some((h) => h.protocol === k.protocol && h.host === k.host)) {
throw err("EBADHOST", `${k.host} is not a remote of this repository`);
}
env.AGENTHUB_GIT_ONCE = JSON.stringify({
...k,
username: String(once.username || ""),
password: String(once.password || ""),
});
} else if (!hosts.some((h) => find(h.protocol, h.host))) {
return { args: [], env: {} };
}
// ponytail: while AgentHub answers for one host, a remote on ANOTHER host
// gets no machine helper either (see the file header); chain the machine's
// helpers for unknown hosts if mixed-host `fetch --all` ever matters.
env.AGENTHUB_GIT_CRED_FILE = credentialsFile();
return {
args: [
"-c",
"credential.helper=",
"-c",
`credential.helper=!${shq(process.execPath)} ${shq(HELPER)}`,
],
env,
};
}
module.exports = {
credentialsFile,
list,
find,
save,
remove,
authFailure,
remoteHosts,
gitConfigFor,
};
+1
View File
@@ -907,6 +907,7 @@ async function listTreeAll(dir) {
module.exports = {
collectChanges,
CONFLICT_CODES,
contentSha,
isSecretOnDisk,
lanePath,
+181 -20
View File
@@ -1,11 +1,13 @@
/**
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
* stage/unstage a file, commit, discard a file, switch branch, and create or
* delete a branch. Everything here is deliberately the boring half of git —
* there is no merge, no rebase, no reset, no stash and no conflict
* resolution, because a dashboard button is the wrong place to start an
* operation whose recovery needs a terminal (`reset` stays where it was:
* behind the three-check destroy guard in worktree.js).
* stage/unstage a file, commit, discard a file, switch branch, create or
* delete a branch, and merge a branch with its conflict resolution (issue
* #45: merge, resolve ours/theirs/hand-edited, merge-commit, merge-abort).
* There is no rebase, no reset and no stash, because a dashboard button is
* the wrong place to start an operation whose recovery needs a terminal
* (`reset` stays where it was: behind the three-check destroy guard in
* worktree.js). A merge is the exception because its recovery is ONE button
* here too — `merge-abort` puts the branch back where it was.
*
* Four rules hold for every action:
* - argv only, via worktree.js's `git()` — never a shell string.
@@ -18,11 +20,13 @@
* `git status` is reporting as changed right now (`resolvePaths`), so no
* `..`, no absolute path and no untouched file is reachable.
* - `pull` is `--ff-only` and `switch` demands a clean tree, so neither can
* invent a merge commit or a conflict underneath a running agent.
* invent a merge commit or a conflict underneath a running agent. A merge
* happens only when asked for by name (`merge`), on a clean tree, from a
* lane that IS the repo root (a merge is repo-wide), one at a time.
*
* `discard` is the ONE irreversible action here — it throws away work git has
* no copy of — so it additionally demands an explicit `confirm: true` in the
* body. Do not add a second destructive action without the same gate.
* `discard`, `discard-hunk` and `merge-abort` throw away work git has no copy
* of, so each demands an explicit `confirm: true` in the body. Do not add
* another destructive action without the same gate.
*
* Network actions carry a timeout: a lane pointed at an unreachable remote
* should fail in half a minute, not hold an HTTP request open.
@@ -39,7 +43,8 @@ const {
branchCheckedOutAt,
statusCounts,
} = require("./worktree");
const { hunkPatch, laneStatus } = require("./git-diff");
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
const gitCredentials = require("./git-credentials");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
@@ -61,8 +66,18 @@ const ACTIONS = new Set([
"discard-hunk",
"branch-create",
"branch-delete",
"merge",
"resolve",
"merge-commit",
"merge-abort",
]);
/** Actions that would tangle with a merge in progress. */
const BLOCKED_WHILE_MERGING = new Set(["pull", "switch", "merge", "stage-hunk", "discard-hunk"]);
/** A line git writes to open or close a conflict block. */
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
/** Branches this module refuses to delete, whatever the request says. */
const PROTECTED = new Set(["main", "master", "development", "develop"]);
@@ -159,6 +174,49 @@ async function resolvePaths(dir, body) {
return wanted.map((p) => ({ path: p, ...changed.get(p) }));
}
/**
* A fetch/pull/push, authenticated with AgentHub's own git credentials
* (git-credentials.js): a stored one for the remote's host, or
* `body.credentials` typed for this one call. A login failure becomes
* EGITAUTH carrying the host, which the git bar turns into a login form; a
* typed credential is saved only after git accepted it and only if asked.
*/
async function networkGit(dir, args, body = {}) {
const typed = body.credentials && typeof body.credentials === "object" ? body.credentials : null;
const cfg = await gitCredentials.gitConfigFor(dir, typed);
try {
const out = await git(dir, [...cfg.args, ...args], {
timeout: NETWORK_TIMEOUT_MS,
env: cfg.env,
});
if (typed?.remember) gitCredentials.save(typed);
return out;
} catch (e) {
const auth = gitCredentials.authFailure(e.git?.stderr);
if (!auth) throw e;
const remote = (await gitCredentials.remoteHosts(dir)).find(
(h) => h.protocol === auth.protocol && h.host === auth.host
);
const stored = gitCredentials.find(auth.protocol, auth.host);
const wrong = Boolean(typed) || Boolean(stored);
throw Object.assign(
new Error(
wrong
? `${auth.host} rejected the username or token`
: `${auth.host} needs a username and password or access token`
),
{
code: "EGITAUTH",
auth: {
...auth,
username: typed?.username || stored?.username || remote?.username || null,
rejected: wrong,
},
}
);
}
}
/**
* Run one action against a lane's working directory.
*
@@ -172,18 +230,19 @@ async function resolvePaths(dir, body) {
*/
async function runGitAction(dir, action, body = {}) {
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
if (BLOCKED_WHILE_MERGING.has(action) && (await isMerging(dir))) {
throw bad("EMERGING", "a merge is in progress: finish or abort it first");
}
if (action.startsWith("merge") || action === "resolve") return mergeAction(dir, action, body);
if (action === "fetch") {
return done(
action,
await git(dir, ["fetch", "--all", "--prune"], { timeout: NETWORK_TIMEOUT_MS })
);
return done(action, await networkGit(dir, ["fetch", "--all", "--prune"], body));
}
if (action === "pull") {
// --ff-only on purpose: a divergent branch is reported, never merged. The
// user (or the agent in the pane) decides how to reconcile it.
return done(action, await git(dir, ["pull", "--ff-only"], { timeout: NETWORK_TIMEOUT_MS }));
return done(action, await networkGit(dir, ["pull", "--ff-only"], body));
}
if (action === "push") {
@@ -191,14 +250,19 @@ async function runGitAction(dir, action, body = {}) {
// is actually on, and -u makes the first push set the upstream the
// ahead/behind counts then read.
const remote = await pushRemote(dir);
return done(
action,
await git(dir, ["push", "-u", remote, "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
);
return done(action, await networkGit(dir, ["push", "-u", remote, "HEAD"], body));
}
if (action === "stage") {
const paths = await resolvePaths(dir, body);
// Staging a conflicted file marks it resolved: not while git's markers
// are still in it (the same check `resolve` makes).
for (const p of paths.filter((p) => CONFLICT_CODES.has(p.code))) {
const file = path.join(dir, p.path);
if (fs.existsSync(file) && MARKER_RE.test(await fs.promises.readFile(file, "utf8"))) {
throw bad("EMARKERS", `${p.path} still has conflict markers`);
}
}
// `add` covers every case the panel can show: a modification, a new file
// and a deletion all stage with the same verb.
return done(action, await git(dir, ["add", "--", ...paths.map((p) => p.path)]));
@@ -336,6 +400,103 @@ async function runGitAction(dir, action, body = {}) {
return done(action, await git(dir, ["switch", branch]), branch);
}
/** Whether a merge is in progress (MERGE_HEAD exists). */
async function isMerging(dir) {
try {
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
return true;
} catch {
return false;
}
}
/** Paths git still holds as unmerged, repo-wide. */
async function unmergedPaths(dir) {
return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout
.split("\0")
.filter(Boolean);
}
/** merge / resolve / merge-commit / merge-abort. */
async function mergeAction(dir, action, body) {
if (action === "merge") {
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
if (!(await isValidBranchName(dir, branch))) {
throw bad("EBADBRANCH", "branch is not a valid git branch name");
}
// A merge moves the whole repo, so only a lane that IS the repo may start
// one — a subdirectory lane could not see, let alone resolve, a conflict
// outside its own folder.
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad(
"ESUBDIR",
"a merge changes the whole repository; run it from a lane at the repo root"
);
}
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
// The ref is looked up, never passed through: git sees the full refname
// of a local or remote-tracking branch that exists, nothing else.
const refs = (
await git(dir, [
"for-each-ref",
"--format=%(refname:short)%00%(refname)",
"refs/heads",
"refs/remotes",
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"));
const ref = refs.find(([short]) => short === branch)?.[1];
if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`);
try {
const out = await git(dir, ["merge", "--no-edit", ref]);
return { ...done(action, out, branch), conflicts: [] };
} catch (e) {
// A conflict is a merge that STARTED: report the files, not a failure.
if (!(await isMerging(dir))) throw e;
return {
...done(action, e.git || { stdout: "", stderr: "" }, branch),
conflicts: await unmergedPaths(dir),
};
}
}
if (action === "resolve") {
const [target] = await resolvePaths(dir, { path: body.path });
if (!CONFLICT_CODES.has(target.code))
throw bad("EBADPATH", `not a conflicted path: ${target.path}`);
const side = body.side;
if (!["ours", "theirs", "done"].includes(side))
throw bad("EBADSIDE", "side must be ours, theirs or done");
const p = target.path;
const gone =
(side === "ours" && target.code[0] === "D") || (side === "theirs" && target.code[1] === "D");
if (side !== "done" && !gone) {
await git(dir, ["checkout", `--${side}`, "--", p]);
return done(action, await git(dir, ["add", "--", p]));
}
if (side === "done" && fs.existsSync(path.join(dir, p))) {
if (MARKER_RE.test(await fs.promises.readFile(path.join(dir, p), "utf8"))) {
throw bad("EMARKERS", `${p} still has conflict markers`);
}
return done(action, await git(dir, ["add", "--", p]));
}
// The chosen side deleted it (or the hand edit did): resolved as gone.
return done(action, await git(dir, ["rm", "-q", "--", p]));
}
if (action === "merge-abort" && body.confirm !== true) {
throw bad("ECONFIRM", "merge-abort requires confirm: true");
}
if (!(await isMerging(dir))) throw bad("ENOMERGE", "no merge in progress");
if (action === "merge-abort") return done(action, await git(dir, ["merge", "--abort"]));
// merge-commit
const left = await unmergedPaths(dir);
if (left.length) throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
return done(action, await git(dir, ["commit", "--no-edit"]));
}
/** Create or delete a branch WITHOUT touching the working tree. */
async function branchAction(dir, action, body) {
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
+30 -2
View File
@@ -30,7 +30,11 @@ const STATUS_FILE_CAP = 20;
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
*/
async function git(cwd, args, { timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false } = {}) {
async function git(
cwd,
args,
{ timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false, env: extraEnv = {} } = {}
) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
const env = { ...process.env };
@@ -60,6 +64,8 @@ async function git(cwd, args, { timeout = 0, maxBuffer = 8 * 1024 * 1024, litera
delete env.GIT_ICASE_PATHSPECS;
if (literal) env.GIT_LITERAL_PATHSPECS = "1";
else delete env.GIT_LITERAL_PATHSPECS;
// Caller-supplied last: the git credential helper's inputs (git-credentials.js).
Object.assign(env, extraEnv);
try {
const result = await execFileAsync("git", args, {
@@ -696,7 +702,29 @@ async function gitFacts(dir) {
const { dirty, untracked, files } = await laneFiles(dir);
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
return { branch, head, subject, dirty, untracked, files, ...(await trackingFacts(dir)) };
return {
branch,
head,
subject,
dirty,
untracked,
files,
...(await trackingFacts(dir)),
...(await mergeFacts(dir)),
};
}
/** A merge in progress and how many files it still holds unmerged (repo-wide). */
async function mergeFacts(dir) {
try {
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
} catch {
return { merging: false, conflicts: 0 };
}
const out = await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]).catch(() => ({
stdout: "",
}));
return { merging: true, conflicts: out.stdout.split("\0").filter(Boolean).length };
}
/**
+47
View File
@@ -0,0 +1,47 @@
/**
* @file HTTP surface for AgentHub's own git credentials (git-credentials.js):
* list, save/replace, delete. Like the AI Router routes, nothing here ever
* returns a password — `GET` reports `hasToken` — and every write is behind
* the same-origin guard. A lane's fetch/pull/push also saves one, through
* `POST /api/lanes/:id/git/:action` with `credentials.remember`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const express = require("express");
const creds = require("../lib/git-credentials");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
function fail(res, e) {
const status = e.code === "EBADHOST" || e.code === "EBADCRED" ? 400 : 500;
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
router.get("/", (_req, res) => {
res.json({ items: creds.list() });
});
router.post("/", sameOriginGuard, (req, res) => {
try {
const { protocol, host, username, password } = req.body || {};
creds.save({ protocol, host, username, password });
res.json({ items: creds.list() });
} catch (e) {
fail(res, e);
}
});
router.delete("/:protocol/:host", sameOriginGuard, (req, res) => {
try {
if (!creds.remove(req.params.protocol, req.params.host)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such credential" } });
}
res.json({ items: creds.list() });
} catch (e) {
fail(res, e);
}
});
module.exports = router;
+9 -1
View File
@@ -973,6 +973,11 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"ESTAGEDOUTSIDE",
"ENOREMOTE",
"EUNTRACKED",
"EBADHOST",
"EBADCRED",
"ESUBDIR",
"EBADSIDE",
"EMARKERS",
];
// A numeric code is git's own exit status (execFile reports one that way):
// git ran and refused — a rejected push, a failed credential, a diverged
@@ -982,7 +987,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
// EHUNKGONE is the same kind of answer: the tree moved under the click.
const status = badRequest.includes(err.code)
? 400
: exited || err.code === "EHUNKGONE"
: exited || ["EHUNKGONE", "EGITAUTH", "EMERGING", "ENOMERGE", "ECONFLICTS"].includes(err.code)
? 409
: 500;
// git's own stderr is the useful half of a failed push or pull (a
@@ -994,6 +999,9 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
error: {
code: gitErrorCode(err),
message: detail ? `${err.message}\n${detail}` : err.message,
// EGITAUTH: which host wants a login, for the git bar's form. Never
// a secret — just protocol, host, a username hint and `rejected`.
...(err.auth ? { auth: err.auth } : {}),
},
});
}