gent-cli 15.0.0 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ /** Independent bounded protocol-v0 client. No external Git engine. */
2
+ const fs = require('fs').promises;
3
+ const path = require('path');
4
+ const axios = require('axios');
5
+ const objects = require('./git-objects');
6
+ const { buildPack, readPackStream } = require('./packfile');
7
+ const { assertRefName: validateRefName } = require('./refs');
8
+ const repository = require('./repository');
9
+ const ops = require('./gent-ops');
10
+ const MAX_BYTES = 128 * 1024 * 1024;
11
+ const ZERO = '0'.repeat(64);
12
+
13
+ function pkt(value) {
14
+ const data = Buffer.isBuffer(value) ? value : Buffer.from(value);
15
+ if (data.length > 65516) throw new Error('packet exceeds limit');
16
+ return Buffer.concat([Buffer.from((data.length + 4).toString(16).padStart(4, '0')), data]);
17
+ }
18
+ function packet(data, pos) {
19
+ const raw = data.toString('ascii', pos, pos + 4);
20
+ if (!/^[0-9a-f]{4}$/i.test(raw)) throw new Error('invalid packet length');
21
+ const size = parseInt(raw, 16);
22
+ if (!size) return { line: null, pos: pos + 4 };
23
+ if (size < 4 || size > 65520 || pos + size > data.length) throw new Error('truncated packet');
24
+ const line = data.subarray(pos + 4, pos + size);
25
+ if (line.toString().startsWith('ERR ')) throw new Error(line.toString().trim());
26
+ return { line, pos: pos + size };
27
+ }
28
+ function remoteUrl(value) {
29
+ const url = new URL(value);
30
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
31
+ throw new Error('use an HTTP(S) URL without credentials, query or fragment');
32
+ }
33
+ if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
34
+ throw new Error('remote connections require HTTPS (HTTP is allowed on loopback for development)');
35
+ }
36
+ return url.toString().replace(/\/$/, '');
37
+ }
38
+ async function request(url, service, body) {
39
+ const headers = {};
40
+ if (process.env.GENT_HTTP_TOKEN) {
41
+ if (!process.env.GENT_HTTP_USER) throw new Error('set GENT_HTTP_USER with GENT_HTTP_TOKEN');
42
+ headers.Authorization = 'Basic ' + Buffer.from(`${process.env.GENT_HTTP_USER}:${process.env.GENT_HTTP_TOKEN}`).toString('base64');
43
+ }
44
+ const suffix = body ? service : `info/refs?service=${service}`;
45
+ if (body) headers['Content-Type'] = `application/x-${service}-request`;
46
+ const result = await axios({ url: `${remoteUrl(url)}/${suffix}`, method: body ? 'POST' : 'GET', data: body,
47
+ headers, responseType: 'arraybuffer', timeout: 30000, maxRedirects: 0,
48
+ maxBodyLength: MAX_BYTES, maxContentLength: MAX_BYTES,
49
+ validateStatus: () => true });
50
+ if (result.status !== 200) throw new Error(`remote returned HTTP ${result.status}`);
51
+ const expected = `application/x-${service}-${body ? 'result' : 'advertisement'}`;
52
+ if (result.headers['content-type']?.split(';')[0] !== expected) throw new Error('invalid smart HTTP content type');
53
+ return Buffer.from(result.data);
54
+ }
55
+ async function discover(url, service = 'git-upload-pack') {
56
+ const data = await request(url, service);
57
+ let row = packet(data, 0);
58
+ if (row.line?.toString() !== `# service=${service}\n`) throw new Error('invalid service advertisement');
59
+ row = packet(data, row.pos);
60
+ if (row.line !== null) throw new Error('missing service flush');
61
+ const refs = new Map(); let caps = [], first = true, pos = row.pos;
62
+ while (pos < data.length) {
63
+ row = packet(data, pos); pos = row.pos;
64
+ if (row.line === null) break;
65
+ const [value, capabilities] = row.line.toString().replace(/\n$/, '').split('\0');
66
+ if (first) caps = (capabilities || '').split(' ');
67
+ else if (capabilities) throw new Error('unexpected capabilities');
68
+ first = false;
69
+ const [oid, name] = value.split(' ');
70
+ objects.assertObjectId(oid);
71
+ if (name === 'capabilities^{}' && oid === ZERO) continue;
72
+ if (name !== 'HEAD') validateRefName(name.replace(/\^\{\}$/, ''));
73
+ refs.set(name, oid);
74
+ }
75
+ if (!caps.includes('object-format=sha256')) throw new Error('remote must negotiate SHA-256');
76
+ return { refs, caps, head: caps.find(c => c.startsWith('symref=HEAD:'))?.slice(12) };
77
+ }
78
+ function dependencies(item) {
79
+ if (item.type === 'blob') return [];
80
+ if (item.type === 'tree') return objects.parseTree(item.payload).filter(e => e.mode !== 0o160000).map(e => [e.oid, e.type]);
81
+ if (item.type === 'commit') {
82
+ const c = objects.parseCommit(item.payload);
83
+ if (!c.author || !c.committer) throw new Error('commit identities required');
84
+ return [[c.tree, 'tree'], ...c.parents.map(p => [p, 'commit'])];
85
+ }
86
+ if (item.type === 'tag') { const t = objects.parseTag(item.payload); return [[t.object, t.targetType]]; }
87
+ throw new Error('invalid object type');
88
+ }
89
+ async function closure(roots, resolve) {
90
+ const result = new Map(), todo = [...roots]; let total = 0;
91
+ while (todo.length) {
92
+ const [oid, expected] = todo.pop();
93
+ const item = result.get(oid) || await resolve(oid);
94
+ if (!item || (expected && item.type !== expected)) throw new Error(`missing or mistyped object ${oid}`);
95
+ if (result.has(oid)) continue;
96
+ if (objects.hashObject(item.type, item.payload) !== oid) throw new Error('object ID mismatch');
97
+ total += item.payload.length;
98
+ if (total > MAX_BYTES || result.size >= 10000) throw new Error('history exceeds transfer limits');
99
+ result.set(oid, { ...item, oid }); todo.push(...dependencies(item));
100
+ }
101
+ return result;
102
+ }
103
+ function nameCheck(name) {
104
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error('remote name must use letters, digits, _ or -');
105
+ }
106
+ function configured(repo, name = 'origin') {
107
+ nameCheck(name);
108
+ const fetchSpecs = repo.config.getAll(`remote.${name}.fetch`);
109
+ if (fetchSpecs.some(spec => spec !== `+refs/heads/*:refs/remotes/${name}/*`)) throw new Error('custom fetch refspecs are not supported; use the full branch mapping');
110
+ for (const key of ['mirror', 'promisor', 'uploadpack', 'receivepack', 'proxy', 'pushurl', 'tagopt']) {
111
+ if (repo.config.get(`remote.${name}.${key}`) !== undefined) throw new Error(`remote.${name}.${key} is not supported by the canonical transport`);
112
+ }
113
+ const url = repo.config.get(`remote.${name}.url`);
114
+ if (!url) throw new Error(`remote '${name}' is not configured; use gent remote add ${name} <url>`);
115
+ return remoteUrl(url);
116
+ }
117
+ async function fetch(repo, name = 'origin', url = configured(repo, name)) {
118
+ nameCheck(name);
119
+ const ad = await discover(url);
120
+ const selected = [...ad.refs].filter(([ref]) => ref.startsWith('refs/heads/') || (ref.startsWith('refs/tags/') && !ref.endsWith('^{}')));
121
+ const updates = [];
122
+ for (const [ref, oid] of selected) {
123
+ const target = ref.startsWith('refs/heads/') ? `refs/remotes/${name}/${ref.slice(11)}` : ref;
124
+ const before = await repo.refs.resolveToOid(target);
125
+ if (ref.startsWith('refs/tags/') && before && before !== oid) throw new Error(`remote tag differs: ${ref}`);
126
+ updates.push({ name: target, newOid: oid, expectedOldOid: before });
127
+ }
128
+ if (selected.length) {
129
+ const wants = [...new Set(selected.map(([, oid]) => oid))];
130
+ const body = Buffer.concat([...wants.map((oid, i) => pkt(`want ${oid}${i ? '' : ' object-format=sha256'}\n`)), Buffer.from('0000'), pkt('done\n')]);
131
+ const data = await request(url, 'git-upload-pack', body);
132
+ const first = packet(data, 0);
133
+ if (first.line?.toString() !== 'NAK\n') throw new Error('unsupported upload response');
134
+ const incoming = await readPackStream(data.subarray(first.pos), { maxObjects: 10000, resolveBase: oid => repo.objects.has(oid).then(has => has ? repo.objects.read(oid) : null) });
135
+ const byOid = new Map(incoming.map(item => [item.oid, item]));
136
+ await closure(selected.map(([ref, oid]) => [oid, ref.startsWith('refs/heads/') ? 'commit' : null]), oid => byOid.get(oid) || repo.objects.read(oid));
137
+ for (const item of incoming) await repo.objects.writeVerified(item.oid, item.type, item.payload);
138
+ await repo.refs.updateMany(updates, `fetch ${name}`);
139
+ }
140
+ return ad;
141
+ }
142
+ async function push(repo, name = 'origin', branch, options = {}) {
143
+ const url = configured(repo, name), head = await repo.refs.head();
144
+ branch ||= head.branch;
145
+ if (!branch) throw new Error('specify a branch from detached HEAD');
146
+ const ref = branch.startsWith('refs/') ? branch : `refs/heads/${branch}`;
147
+ validateRefName(ref);
148
+ if (!ref.startsWith('refs/heads/') && !ref.startsWith('refs/tags/')) throw new Error('only branches and tags may be pushed');
149
+ const target = await repo.refs.resolveToOid(ref);
150
+ if (!target) throw new Error(`unknown ref ${ref}`);
151
+ const ad = await discover(url, 'git-receive-pack'), old = ad.refs.get(ref) || ZERO;
152
+ if (target === old) return;
153
+ if (old !== ZERO && !options.force && (ref.startsWith('refs/tags/') || !(await ops.isAncestor(repo, old, target)))) {
154
+ throw new Error('non-fast-forward push; fetch and merge first');
155
+ }
156
+ if (!ad.caps.includes('report-status')) throw new Error('remote must report ref status');
157
+ const all = await closure([[target, ref.startsWith('refs/heads/') ? 'commit' : null]], oid => repo.objects.read(oid));
158
+ const body = Buffer.concat([pkt(`${old} ${target} ${ref}\0report-status object-format=sha256\n`), Buffer.from('0000'), buildPack([...all.values()]).pack]);
159
+ const data = await request(url, 'git-receive-pack', body);
160
+ let pos = 0; const lines = [];
161
+ while (pos < data.length) { const row = packet(data, pos); pos = row.pos; if (row.line === null) break; lines.push(row.line.toString().trim()); }
162
+ if (lines[0] !== 'unpack ok' || !lines.includes(`ok ${ref}`) || lines.some(line => line.startsWith('ng '))) throw new Error(`push rejected: ${lines.join('; ')}`);
163
+ }
164
+ async function clone(url, directory) {
165
+ url = remoteUrl(url);
166
+ const ad = await discover(url);
167
+ const destination = path.resolve(directory || new URL(url).pathname.split('/').pop().replace(/\.git$/, ''));
168
+ // Exclusive directory creation avoids touching existing user files on failure.
169
+ await fs.mkdir(destination);
170
+ const branch = ad.head?.startsWith('refs/heads/') ? ad.head.slice(11) : 'main';
171
+ validateRefName(`refs/heads/${branch}`);
172
+ const { repo } = await repository.init(destination, { defaultBranch: branch });
173
+ repo.localConfig.set('remote.origin.url', url);
174
+ repo.localConfig.set('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*');
175
+ repo.localConfig.set(`branch.${branch}.remote`, 'origin');
176
+ repo.localConfig.set(`branch.${branch}.merge`, `refs/heads/${branch}`);
177
+ await repo.localConfig.save();
178
+ const current = await fetch(repo, 'origin', url), tip = current.refs.get(`refs/heads/${branch}`);
179
+ if (tip) {
180
+ await repo.refs.update(`refs/heads/${branch}`, tip, { expectedOldOid: null, reason: 'clone' });
181
+ await ops.checkout(repo, branch, { force: true });
182
+ }
183
+ return destination;
184
+ }
185
+ async function migrationInfo(url) {
186
+ const headers = {};
187
+ if (process.env.GENT_HTTP_TOKEN) {
188
+ if (!process.env.GENT_HTTP_USER) throw new Error('set GENT_HTTP_USER with GENT_HTTP_TOKEN');
189
+ headers.Authorization = 'Basic ' + Buffer.from(`${process.env.GENT_HTTP_USER}:${process.env.GENT_HTTP_TOKEN}`).toString('base64');
190
+ }
191
+ const result = await axios.get(remoteUrl(url) + '/gent-migration', { headers, timeout: 30000, maxRedirects: 0, maxContentLength: MAX_BYTES });
192
+ if (result.data?.format !== 'gent-migration-1' || result.data.object_format !== 'sha256') throw new Error('server cutover must finish before connected migration');
193
+ return result.data;
194
+ }
195
+ module.exports = { migrationInfo, pkt, packet, discover, closure, fetch, push, clone, configured, remoteUrl, nameCheck };
@@ -0,0 +1,355 @@
1
+ /**
2
+ * ============================================================================
3
+ * Stash Ops - real stash commits on refs/stash
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Shelve working-tree and index state as genuine commits, so a stash Gent
8
+ * creates can be inspected, applied or dropped by Git and vice versa.
9
+ * The v12 JSON stack could do none of that.
10
+ *
11
+ * TOPOLOGY (Git's):
12
+ * i = commit(tree = index tree, parents = [HEAD])
13
+ * u = commit(tree = untracked files, parents = []) (optional)
14
+ * W = commit(tree = working tree, parents = [HEAD, i, u?])
15
+ * refs/stash -> W, and the *stack* is refs/stash's reflog: stash@{0} is the
16
+ * newest entry, stash@{1} the one before it, and so on.
17
+ *
18
+ * DROPPING:
19
+ * Removing stash@{N} means rewriting the reflog and repointing refs/stash at
20
+ * the new newest entry — exactly what Git does. Dropping the last entry
21
+ * deletes the ref.
22
+ * ============================================================================
23
+ */
24
+
25
+ const fs = require('fs').promises;
26
+ const path = require('path');
27
+
28
+ const { serializeCommit, serializeTree, MODE, NULL_OID } = require('./git-objects');
29
+ const { GitIndex, IndexEntry } = require('./git-index');
30
+ const { AttributesMatcher } = require('./attributes');
31
+ const { IgnoreMatcher, walkWorktree } = require('./ignore');
32
+ const worktree = require('./worktree');
33
+ const ops = require('./gent-ops');
34
+ const { writeAtomic, readFileOrNull, withLock } = require('./lockfile');
35
+
36
+ const STASH_REF = 'refs/stash';
37
+
38
+ class StashError extends Error {
39
+ constructor(message, code) {
40
+ super(message);
41
+ this.name = 'StashError';
42
+ this.code = code || 'GENT_STASH';
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Build a tree from an explicit path -> {mode, oid} map.
48
+ * @param {Object} repo
49
+ * @param {Map} entries
50
+ * @returns {Promise<String>}
51
+ */
52
+ async function buildTree(repo, entries) {
53
+ const root = new Map();
54
+ for (const [filePath, entry] of entries) {
55
+ const parts = filePath.split('/');
56
+ let node = root;
57
+ for (let i = 0; i < parts.length - 1; i++) {
58
+ if (!node.has(parts[i])) node.set(parts[i], new Map());
59
+ node = node.get(parts[i]);
60
+ }
61
+ node.set(parts[parts.length - 1], entry);
62
+ }
63
+
64
+ async function store(node) {
65
+ const list = [];
66
+ for (const [name, child] of node) {
67
+ list.push(child instanceof Map
68
+ ? { mode: MODE.TREE, name, oid: await store(child) }
69
+ : { mode: child.mode, name, oid: child.oid });
70
+ }
71
+ return repo.objects.write('tree', serializeTree(list));
72
+ }
73
+ return store(root);
74
+ }
75
+
76
+ /**
77
+ * Shelve the current changes.
78
+ *
79
+ * @param {Object} repo
80
+ * @param {Object} [options]
81
+ * @param {String} [options.message]
82
+ * @param {Boolean} [options.includeUntracked]
83
+ * @param {Boolean} [options.keepIndex]
84
+ * @returns {Promise<{oid: String, message: String}>}
85
+ */
86
+ async function push(repo, options = {}) {
87
+ await repo.assertNoExternalOperation('gent stash');
88
+ repo.requireWorktree('gent stash');
89
+
90
+ const head = await repo.refs.head();
91
+ if (!head.oid) throw new StashError('cannot stash before the first commit');
92
+
93
+ const index = await GitIndex.read(repo.indexPath);
94
+ if (index.hasConflicts()) throw new StashError('cannot stash while a merge is unresolved');
95
+
96
+ const state = await ops.status(repo, { index });
97
+ const untracked = options.includeUntracked ? state.untracked : [];
98
+ if (!state.staged.length && !state.unstaged.length && !untracked.length) {
99
+ throw new StashError('there is nothing to stash', 'GENT_NOTHING_TO_STASH');
100
+ }
101
+
102
+ const identity = await repo.identity('committer');
103
+ if (!identity) throw new StashError('stashing needs user.name and user.email', 'GENT_NO_IDENTITY');
104
+
105
+ const headCommit = await repo.objects.readCommit(head.oid);
106
+ const branchLabel = head.branch || `(no branch) ${head.oid.slice(0, 7)}`;
107
+ const subject = headCommit.message.toString('utf-8').split('\n')[0];
108
+
109
+ // i: the index exactly as it stands.
110
+ const indexTree = await worktree.buildTreeFromIndex(repo, index);
111
+ const indexCommit = await repo.objects.write('commit', serializeCommit({
112
+ tree: indexTree,
113
+ parents: [head.oid],
114
+ author: identity,
115
+ committer: identity,
116
+ message: Buffer.from(`index on ${branchLabel}: ${head.oid.slice(0, 7)} ${subject}\n`, 'utf-8')
117
+ }));
118
+
119
+ // u: untracked files, parentless.
120
+ let untrackedCommit = null;
121
+ if (untracked.length) {
122
+ const attributes = new AttributesMatcher(repo);
123
+ const entries = new Map();
124
+ for (const relativePath of untracked) {
125
+ const staged = await worktree.stageWorktreeFile(repo, attributes, relativePath);
126
+ entries.set(relativePath, { mode: staged.mode, oid: staged.oid });
127
+ }
128
+ const untrackedTree = await buildTree(repo, entries);
129
+ untrackedCommit = await repo.objects.write('commit', serializeCommit({
130
+ tree: untrackedTree,
131
+ parents: [],
132
+ author: identity,
133
+ committer: identity,
134
+ message: Buffer.from(`untracked files on ${branchLabel}: ${head.oid.slice(0, 7)} ${subject}\n`, 'utf-8')
135
+ }));
136
+ }
137
+
138
+ // W: the working tree as it stands.
139
+ const attributes = new AttributesMatcher(repo);
140
+ const worktreeEntries = new Map();
141
+ for (const entry of index.staged()) {
142
+ const absolute = path.join(repo.worktree, ...entry.path.split('/'));
143
+ const stat = await fs.lstat(absolute).catch(() => null);
144
+ if (!stat) continue; // deleted in the working tree
145
+ const staged = await worktree.stageWorktreeFile(repo, attributes, entry.path);
146
+ worktreeEntries.set(entry.path, { mode: staged.mode, oid: staged.oid });
147
+ }
148
+ const worktreeTree = await buildTree(repo, worktreeEntries);
149
+
150
+ const parents = [head.oid, indexCommit];
151
+ if (untrackedCommit) parents.push(untrackedCommit);
152
+
153
+ const message = options.message
154
+ ? `On ${branchLabel}: ${options.message}`
155
+ : `WIP on ${branchLabel}: ${head.oid.slice(0, 7)} ${subject}`;
156
+
157
+ const stashCommit = await repo.objects.write('commit', serializeCommit({
158
+ tree: worktreeTree,
159
+ parents,
160
+ author: identity,
161
+ committer: identity,
162
+ message: Buffer.from(message + '\n', 'utf-8')
163
+ }));
164
+
165
+ const previous = await repo.refs.resolveToOid(STASH_REF);
166
+ await repo.refs.update(STASH_REF, stashCommit, { expectedOldOid: previous, reason: message });
167
+
168
+ // Restore the worktree and index to HEAD, then drop untracked files.
169
+ await ops.reset(repo, 'hard', 'HEAD');
170
+ for (const relativePath of untracked) {
171
+ await fs.rm(path.join(repo.worktree, ...relativePath.split('/')), { force: true });
172
+ await worktree.removeEmptyParents(repo, relativePath);
173
+ }
174
+ if (options.keepIndex) {
175
+ const restored = await worktree.readTreeRecursive(repo, indexTree);
176
+ const rebuilt = await GitIndex.read(repo.indexPath);
177
+ const current = new Map(rebuilt.staged().map(e => [e.path, { mode: e.mode, oid: e.oid }]));
178
+ const plan = await worktree.planCheckout(repo, { from: current, to: restored, index: rebuilt, force: true });
179
+ await worktree.applyCheckout(repo, plan, { index: rebuilt });
180
+ await rebuilt.write(repo.indexPath);
181
+ await worktree.completeCheckout(repo);
182
+ }
183
+
184
+ return { oid: stashCommit, message };
185
+ }
186
+
187
+ /**
188
+ * The stash stack, newest first, read from refs/stash's reflog.
189
+ * @param {Object} repo
190
+ * @returns {Promise<Array<{index: Number, oid: String, message: String}>>}
191
+ */
192
+ async function list(repo) {
193
+ const reflog = await repo.refs.readReflog(STASH_REF);
194
+ return reflog
195
+ .map((entry, position) => ({ position, oid: entry.newOid, message: entry.message }))
196
+ .reverse()
197
+ .map((entry, index) => ({ index, oid: entry.oid, message: entry.message, position: entry.position }));
198
+ }
199
+
200
+ /**
201
+ * @param {Object} repo
202
+ * @param {Number} index
203
+ * @returns {Promise<{index, oid, message, position}>}
204
+ */
205
+ async function at(repo, index = 0) {
206
+ const stack = await list(repo);
207
+ const found = stack[index];
208
+ if (!found) throw new StashError(`stash@{${index}} does not exist (the stack holds ${stack.length})`);
209
+ return found;
210
+ }
211
+
212
+ /**
213
+ * Restore a stash into the working tree.
214
+ *
215
+ * @param {Object} repo
216
+ * @param {Number} index
217
+ * @param {Object} [options]
218
+ * @param {Boolean} [options.restoreIndex] - also restore the staged state
219
+ * @returns {Promise<{written: Number, deleted: Number, untracked: Number}>}
220
+ */
221
+ async function apply(repo, index = 0, options = {}) {
222
+ await repo.assertNoExternalOperation('gent stash apply');
223
+ repo.requireWorktree('gent stash apply');
224
+
225
+ const entry = await at(repo, index);
226
+ const stash = await repo.objects.readCommit(entry.oid);
227
+ if (stash.parents.length < 2) throw new StashError(`${entry.oid.slice(0, 12)} is not a stash commit`);
228
+
229
+ await worktree.assertNoPendingCheckout(repo, 'gent stash apply');
230
+ const currentIndex = await GitIndex.read(repo.indexPath);
231
+ const state = await ops.status(repo, { index: currentIndex });
232
+ if (state.staged.length || state.unstaged.length || state.conflicted.length) {
233
+ throw new StashError('commit or stash local changes before applying a stash');
234
+ }
235
+ const current = new Map(currentIndex.staged().map(e => [e.path, { mode: e.mode, oid: e.oid }]));
236
+ const base = await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(stash.parents[0])).tree);
237
+ const saved = await worktree.readTreeRecursive(repo, stash.tree);
238
+ const target = await mergeStashTrees(repo, base, current, saved);
239
+ let stagedTarget = current;
240
+ if (options.restoreIndex) {
241
+ const savedIndex = await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(stash.parents[1])).tree);
242
+ stagedTarget = await mergeStashTrees(repo, base, current, savedIndex);
243
+ }
244
+
245
+ let untrackedCount = 0;
246
+ if (stash.parents.length >= 3) {
247
+ const files = await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(stash.parents[2])).tree);
248
+ for (const [name, item] of files) {
249
+ if (current.has(name) || await worktree.snapshotPath(repo, name)) {
250
+ throw new StashError(`cannot restore untracked '${name}': path already exists`);
251
+ }
252
+ target.set(name, item);
253
+ untrackedCount++;
254
+ }
255
+ }
256
+ const plan = await worktree.planCheckout(repo, { from: current, to: target, index: currentIndex });
257
+ // The working snapshot is not the staged snapshot. Default apply leaves
258
+ // the index untouched; --index restores only the saved staged changes.
259
+ if (options.restoreIndex) {
260
+ currentIndex.entries = [];
261
+ currentIndex.extensions.clear();
262
+ for (const [name, item] of stagedTarget) currentIndex.add(new IndexEntry({ path: name, ...item }));
263
+ currentIndex.serialize();
264
+ }
265
+ const applied = await worktree.applyCheckout(repo, plan);
266
+ if (options.restoreIndex) await currentIndex.write(repo.indexPath);
267
+ await worktree.completeCheckout(repo);
268
+
269
+ return { ...applied, untracked: untrackedCount };
270
+ }
271
+
272
+ /**
273
+ * Remove one entry from the stack, rewriting the reflog as Git does.
274
+ * @param {Object} repo
275
+ * @param {Number} index
276
+ * @returns {Promise<String>} the dropped oid
277
+ */
278
+ async function drop(repo, index = 0) {
279
+ await repo.assertNoExternalOperation('gent stash drop');
280
+ const entry = await at(repo, index);
281
+ const reflogPath = repo.refs.reflogPath(STASH_REF);
282
+
283
+ const raw = await readFileOrNull(reflogPath);
284
+ const lines = raw ? raw.toString('utf-8').split('\n').filter(Boolean) : [];
285
+ if (entry.position >= lines.length) throw new StashError(`stash@{${index}} is no longer in the reflog`);
286
+ lines.splice(entry.position, 1);
287
+
288
+ if (!lines.length) {
289
+ await repo.refs.delete(STASH_REF, { expectedOldOid: undefined });
290
+ await fs.rm(reflogPath, { force: true });
291
+ return entry.oid;
292
+ }
293
+
294
+ await writeAtomic(reflogPath, lines.join('\n') + '\n');
295
+
296
+ // refs/stash must name the newest surviving entry.
297
+ const newest = lines[lines.length - 1].trim().split(/\s+/)[1];
298
+ await withLock(repo.refs.refPath(STASH_REF), async (lock) => {
299
+ await lock.write(`${newest}\n`);
300
+ });
301
+ repo.refs.invalidate();
302
+ return entry.oid;
303
+ }
304
+
305
+ /**
306
+ * Apply then drop.
307
+ * @param {Object} repo
308
+ * @param {Number} index
309
+ * @param {Object} [options]
310
+ * @returns {Promise<Object>}
311
+ */
312
+ async function pop(repo, index = 0, options = {}) {
313
+ const result = await apply(repo, index, options);
314
+ await drop(repo, index);
315
+ return result;
316
+ }
317
+
318
+ /** Merge stash changes relative to their original HEAD, preserving later commits.
319
+ * Conflicting applications refuse before mutation; the stash remains available.
320
+ */
321
+ async function mergeStashTrees(repo, base, current, saved) {
322
+ const { mergeFileContent } = require('./merge-engine');
323
+ const { looksBinary } = require('./attributes');
324
+ const result = new Map();
325
+ const same = (a, b) => (!a && !b) || (a && b && a.oid === b.oid && a.mode === b.mode);
326
+ for (const name of new Set([...base.keys(), ...current.keys(), ...saved.keys()])) {
327
+ const b = base.get(name), ours = current.get(name), theirs = saved.get(name);
328
+ let item;
329
+ if (same(b, theirs) || same(ours, theirs)) item = ours;
330
+ else if (same(b, ours)) item = theirs;
331
+ else {
332
+ if (!ours || !theirs || ours.mode !== theirs.mode || ![MODE.REGULAR, MODE.EXECUTABLE].includes(ours.mode)) {
333
+ throw new StashError(`stash conflicts at '${name}'; no changes applied`);
334
+ }
335
+ const bytes = await Promise.all([b ? repo.objects.readBlob(b.oid) : Buffer.alloc(0), repo.objects.readBlob(ours.oid), repo.objects.readBlob(theirs.oid)]);
336
+ if (bytes.some(looksBinary)) throw new StashError(`binary stash conflict at '${name}'; no changes applied`);
337
+ const merged = mergeFileContent(...bytes.map(value => value.toString('utf8')), name);
338
+ if (merged.hasConflicts) throw new StashError(`stash conflicts at '${name}'; no changes applied`);
339
+ item = { mode: ours.mode, oid: await repo.objects.write('blob', Buffer.from(merged.content)) };
340
+ }
341
+ if (item) result.set(name, item);
342
+ }
343
+ return result;
344
+ }
345
+
346
+ module.exports = {
347
+ StashError,
348
+ STASH_REF,
349
+ push,
350
+ list,
351
+ at,
352
+ apply,
353
+ pop,
354
+ drop
355
+ };
@@ -21,6 +21,10 @@
21
21
  * ai.api_key Anthropic API key
22
22
  * ai.model Model id (e.g. claude-opus-4-7, claude-haiku-4-5)
23
23
  * api.base_url Backend base URL (e.g. http://localhost:8000)
24
+ * web.base_url Web app (frontend) base URL (e.g. https://gent-nu2e.onrender.com)
25
+ * Used by `gent web` / `gent share`. This is a SEPARATE
26
+ * deployment from api.base_url — never derive one from
27
+ * the other.
24
28
  * user.name Default author name
25
29
  * user.email Default author email
26
30
  *
@@ -42,6 +46,7 @@ const ALLOWED_KEYS = new Set([
42
46
  'ai.api_key',
43
47
  'ai.model',
44
48
  'api.base_url',
49
+ 'web.base_url',
45
50
  'user.name',
46
51
  'user.email',
47
52
  ]);
@@ -49,12 +54,17 @@ const ALLOWED_KEYS = new Set([
49
54
  const DEFAULTS = {
50
55
  'ai.model': 'claude-opus-4-7',
51
56
  'api.base_url': 'https://gent-api.onrender.com',
57
+ // The frontend has no production deployment yet; the server's own
58
+ // FRONTEND_URL setting defaults to the same value. Override with
59
+ // `gent config set web.base_url <url>` or GENT_WEB_URL.
60
+ 'web.base_url': 'https://gent-nu2e.onrender.com',
52
61
  };
53
62
 
54
63
  const ENV_OVERRIDES = {
55
64
  'ai.api_key': 'ANTHROPIC_API_KEY',
56
65
  'ai.model': 'GENT_AI_MODEL',
57
66
  'api.base_url': 'GENT_API_URL',
67
+ 'web.base_url': 'GENT_WEB_URL',
58
68
  };
59
69
 
60
70
  function getConfigPath() {