feat(git): remember HTTPS credentials in the app secret store (#321)

Git no longer asks for the HTTPS user name and password on every fetch, pull and push. The password prompt offers "Remember for this host"; the credential is saved in the app's secret store (next to the saved SSH passwords, never in git's global configuration) only after the git command that used it succeeds, and a stored credential the server rejects is forgotten so the next command asks again. Settings › Version Control › Git lists saved credentials and forgets them.

Each git child gets its own id, passed to the askpass helper, so concurrent commands never save or forget each other's credentials. ide-vcs reports command completion through an observer installed at start-up instead of depending on ide-ssh. Credentials are keyed by scheme, host and port, and only git's authentication errors (Authentication failed, HTTP 401/403 responses, invalid username or password) forget a stored credential.

Fixes #272

Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-10-30 10:48:29 +08:00
1 джерело 2c10b63f76
коміт e85ffa2ae8
17 files changed
+666 -34

No files matched your search

+63
Переглянути файл
@@ -139,6 +139,58 @@ function ProtectedBranchesRow() {
);
}
/** Saved git HTTPS credentials (Settings › Git › Passwords): list and forget. */
function GitCredentialsRow() {
const [hosts, setHosts] = useState<string[]>([]);
useEffect(() => {
if (!isTauri()) return;
void commands.gitCredentials().then(setHosts, () => undefined);
}, []);
const forget = async (key: string) => {
await commands.gitCredentialForget(key);
setHosts((h) => h.filter((k) => k !== key));
};
if (hosts.length === 0)
return (
<Row
label="Passwords"
hint={
'Credentials the IDE answers git\'s HTTPS prompts with. Saved after a successful fetch / pull / push when "Remember for this host" was checked.'
}
>
<span className={styles.empty}>
<T>No saved credentials</T>
</span>
</Row>
);
return (
<Row
label="Passwords"
stacked
hint={
"Credentials the IDE answers git's HTTPS prompts with, stored in the app's own secret file (not git's credential.helper). A rejected credential is forgotten automatically."
}
>
<div className={styles.tableBox}>
<table className={styles.table} aria-label={tx("Saved git credentials")}>
<tbody>
{hosts.map((h) => (
<tr key={h}>
<td className={styles.mono}>{h}</td>
<td style={{ width: 90 }}>
<Button size="small" onClick={() => void forget(h)}>
<T>Forget</T>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Row>
);
}
function LargeFilesRow() {
return (
<ToggleNumber
@@ -520,6 +572,17 @@ export const VCS_PAGES: PageSpec[] = [
},
],
},
{
title: "Passwords",
options: [
{
kind: "custom",
label: "Passwords",
keywords: ["credentials", "remember", "forget", "https"],
component: GitCredentialsRow,
},
],
},
{
title: "Stash",
options: [
+5 -2
Переглянути файл
@@ -1,7 +1,7 @@
/** The oldest pending askpass prompt as a modal: password / text field, or Yes / No for a host key. */
import { useState } from "react";
import { useTx } from "@/platform/i18n";
import { Dialog, Input } from "@/ui";
import { Checkbox, Dialog, Input } from "@/ui";
import { answerAskpass, askpassAnswer, useAskpass, type AskpassPrompt } from "./askpass";
import styles from "./Ssh.module.css";
@@ -13,7 +13,9 @@ export function AskpassHost() {
function AskpassDialog({ prompt: p }: { prompt: AskpassPrompt }) {
const tx = useTx();
const [text, setText] = useState("");
const done = (choice: Parameters<typeof askpassAnswer>[1]) => answerAskpass(p.id, askpassAnswer(p.kind, choice));
const [remember, setRemember] = useState(true);
const done = (choice: Parameters<typeof askpassAnswer>[1]) =>
answerAskpass(p, askpassAnswer(p.kind, choice), remember);
const cancel = () => done(null);
if (p.kind === "confirm")
@@ -62,6 +64,7 @@ function AskpassDialog({ prompt: p }: { prompt: AskpassPrompt }) {
value={text}
onChange={(e) => setText(e.target.value)}
/>
{p.remember && <Checkbox checked={remember} onChange={setRemember} label={tx("Remember for this host")} />}
</form>
</Dialog>
);
+12 -3
Переглянути файл
@@ -9,6 +9,8 @@ export interface AskpassPrompt {
id: number;
prompt: string;
kind: AskpassKind;
/** The dialog offers "Remember" (git HTTPS passwords). */
remember: boolean;
}
export const useAskpass = create<{ queue: AskpassPrompt[] }>(() => ({ queue: [] }));
@@ -32,7 +34,14 @@ export function askpassAnswer(
return kind === "confirm" ? null : choice.text;
}
export function answerAskpass(id: number, answer: string | null, send = commands.sshAskpassAnswer) {
dropAskpass(id);
void send(id, answer).catch(() => {});
export function answerAskpass(
p: AskpassPrompt,
answer: string | null,
remember: boolean,
send = commands.sshAskpassAnswer,
) {
dropAskpass(p.id);
// The backend ties `remember` to this prompt; the git command saves it only
// on success and drops it again when authentication fails.
void send(p.id, answer, remember).catch(() => {});
}
+1 -1
Переглянути файл
@@ -13,7 +13,7 @@ import { chooseSshSession, SSH_SETTINGS_PAGE, startSshSession } from "./session"
export function activate(): () => void {
const disposers = [
registerOverlay({ id: "ssh.askpass", component: AskpassHost }),
onIdeEvent("askpass", (e) => enqueueAskpass({ id: e.id, prompt: e.prompt, kind: e.kind })),
onIdeEvent("askpass", (e) => enqueueAskpass({ id: e.id, prompt: e.prompt, kind: e.kind, remember: e.remember })),
onIdeEvent("askpassDone", (e) => dropAskpass(e.id)),
registerActions([
{
+10 -10
Переглянути файл
@@ -7,26 +7,26 @@ describe("askpass queue", () => {
beforeEach(() => useAskpass.setState({ queue: [] }));
it("queues prompts in arrival order, once each", () => {
enqueueAskpass({ id: 1, prompt: "Password:", kind: "secret" });
enqueueAskpass({ id: 2, prompt: "Continue connecting (yes/no)?", kind: "confirm" });
enqueueAskpass({ id: 1, prompt: "Password:", kind: "secret" });
enqueueAskpass({ id: 1, prompt: "Password:", kind: "secret", remember: false });
enqueueAskpass({ id: 2, prompt: "Continue connecting (yes/no)?", kind: "confirm", remember: false });
enqueueAskpass({ id: 1, prompt: "Password:", kind: "secret", remember: false });
expect(useAskpass.getState().queue.map((p) => p.id)).toEqual([1, 2]);
});
it("drops a prompt answered elsewhere", () => {
enqueueAskpass({ id: 1, prompt: "a", kind: "text" });
enqueueAskpass({ id: 2, prompt: "b", kind: "text" });
enqueueAskpass({ id: 1, prompt: "a", kind: "text", remember: false });
enqueueAskpass({ id: 2, prompt: "b", kind: "text", remember: false });
dropAskpass(1);
dropAskpass(9);
expect(useAskpass.getState().queue.map((p) => p.id)).toEqual([2]);
});
it("answers the backend and shows the next prompt", () => {
const send = vi.fn((_id: number, _answer: string | null) => Promise.resolve());
enqueueAskpass({ id: 1, prompt: "a", kind: "secret" });
enqueueAskpass({ id: 2, prompt: "b", kind: "secret" });
answerAskpass(1, "hunter2", send);
expect(send).toHaveBeenCalledWith(1, "hunter2");
const send = vi.fn((_id: number, _answer: string | null, _remember: boolean) => Promise.resolve());
enqueueAskpass({ id: 1, prompt: "a", kind: "secret", remember: true });
enqueueAskpass({ id: 2, prompt: "b", kind: "secret", remember: false });
answerAskpass({ id: 1, prompt: "a", kind: "secret", remember: true }, "hunter2", true, send);
expect(send).toHaveBeenCalledWith(1, "hunter2", true);
expect(useAskpass.getState().queue.map((p) => p.id)).toEqual([2]);
});