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
+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 };