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.
- package/README.md +1 -1
- package/package.json +7 -4
- package/src/commands/canonical.js +242 -0
- package/src/commands/clone.js +2 -13
- package/src/commands/pet.js +23 -45
- package/src/commands/push.js +1 -1
- package/src/commands/share.js +40 -15
- package/src/commands/web.js +30 -15
- package/src/index.js +70 -40
- package/src/utils/api-client.js +6 -9
- package/src/utils/attributes.js +280 -0
- package/src/utils/canonical-journal.js +122 -0
- package/src/utils/feature-support.js +92 -0
- package/src/utils/feature-support.json +210 -0
- package/src/utils/gent-ops.js +836 -0
- package/src/utils/git-config.js +606 -0
- package/src/utils/git-index.js +581 -0
- package/src/utils/git-objects.js +537 -0
- package/src/utils/ignore.js +365 -0
- package/src/utils/lockfile.js +219 -0
- package/src/utils/merge-ops.js +282 -0
- package/src/utils/migrate.js +257 -0
- package/src/utils/object-store.js +332 -36
- package/src/utils/packfile.js +812 -0
- package/src/utils/refs.js +595 -0
- package/src/utils/repository.js +533 -0
- package/src/utils/smart-http.js +195 -0
- package/src/utils/stash-ops.js +355 -0
- package/src/utils/user-config.js +10 -0
- package/src/utils/web-urls.js +125 -0
- package/src/utils/worktree.js +676 -0
|
@@ -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
|
+
};
|
package/src/utils/user-config.js
CHANGED
|
@@ -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() {
|