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:
@@ -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`);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -907,6 +907,7 @@ async function listTreeAll(dir) {
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
CONFLICT_CODES,
|
||||
contentSha,
|
||||
isSecretOnDisk,
|
||||
lanePath,
|
||||
|
||||
+181
-20
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user