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,282 @@
1
+ /**
2
+ * ============================================================================
3
+ * Merge Ops - three-way merge on canonical trees
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Merge two commits using Gent's own diff3 content merge, but reading and
8
+ * writing standard Git structures: nested trees, index stages 1/2/3,
9
+ * MERGE_HEAD / MERGE_MSG, and a real two-parent commit.
10
+ *
11
+ * WHY THE CONTENT MERGE IS REUSED:
12
+ * merge-engine.js already implements diff3 with the union/region handling
13
+ * this project tested. Only its *inputs and outputs* were wrong — flat JSON
14
+ * trees instead of Git objects. This module supplies canonical trees and
15
+ * records the result where Git expects it.
16
+ *
17
+ * STATE:
18
+ * A conflicted merge leaves MERGE_HEAD, MERGE_MSG, conflict stages in the
19
+ * index and marker files in the working tree — exactly what `git status`
20
+ * and `git merge --abort` understand. State is cleared only on a successful
21
+ * commit or an explicit abort.
22
+ * ============================================================================
23
+ */
24
+
25
+ const fs = require('fs').promises;
26
+ const path = require('path');
27
+
28
+ const { MODE } = require('./git-objects');
29
+ const { GitIndex, IndexEntry } = require('./git-index');
30
+ const { AttributesMatcher, looksBinary } = require('./attributes');
31
+ const { mergeFileContent } = require('./merge-engine');
32
+ const worktree = require('./worktree');
33
+ const ops = require('./gent-ops');
34
+ const { writeAtomic } = require('./lockfile');
35
+
36
+ class MergeError extends Error {
37
+ constructor(message, code) {
38
+ super(message);
39
+ this.name = 'MergeError';
40
+ this.code = code || 'GENT_MERGE';
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Decide one path's fate given its three sides.
46
+ * @param {Object|null} base
47
+ * @param {Object|null} ours
48
+ * @param {Object|null} theirs
49
+ * @returns {{kind: String, entry?: Object}}
50
+ */
51
+ function classify(base, ours, theirs) {
52
+ const same = (a, b) => (a === null && b === null) || (a && b && a.oid === b.oid && a.mode === b.mode);
53
+
54
+ if (same(ours, theirs)) return { kind: 'agree', entry: ours };
55
+ if (same(base, ours)) return { kind: 'take-theirs', entry: theirs };
56
+ if (same(base, theirs)) return { kind: 'take-ours', entry: ours };
57
+
58
+ if (!ours && !theirs) return { kind: 'agree', entry: null };
59
+ if (!ours || !theirs) return { kind: 'modify-delete' };
60
+ if (ours.mode !== theirs.mode) return { kind: 'mode-conflict' };
61
+ return { kind: 'content' };
62
+ }
63
+
64
+ /**
65
+ * Merge `theirRevision` into HEAD.
66
+ *
67
+ * @param {Object} repo
68
+ * @param {String} theirRevision
69
+ * @param {Object} [options]
70
+ * @param {String} [options.message]
71
+ * @param {Boolean} [options.noFastForward]
72
+ * @returns {Promise<Object>} a result describing what happened
73
+ */
74
+ async function merge(repo, theirRevision, options = {}) {
75
+ await repo.assertNoExternalOperation('gent merge');
76
+ await worktree.assertNoPendingCheckout(repo, 'gent merge');
77
+ repo.requireWorktree('gent merge');
78
+
79
+ const head = await repo.refs.head();
80
+ if (!head.oid) throw new MergeError('cannot merge before the first commit');
81
+
82
+ const theirs = await ops.peelToCommit(repo, theirRevision);
83
+ const ours = head.oid;
84
+
85
+ if (ours === theirs) return { status: 'up-to-date', oid: ours };
86
+
87
+ const baseOid = await ops.findMergeBase(repo, ours, theirs);
88
+ if (!baseOid) throw new MergeError(`'${theirRevision}' and the current branch share no history`, 'GENT_UNRELATED');
89
+
90
+ if (baseOid === theirs) return { status: 'up-to-date', oid: ours };
91
+
92
+ const index = await GitIndex.read(repo.indexPath);
93
+ const dirty = await ops.status(repo, { index });
94
+ if (dirty.unstaged.length || dirty.staged.length) {
95
+ throw new MergeError(
96
+ `you have local changes; commit or stash them before merging.\n` +
97
+ [...dirty.staged, ...dirty.unstaged].map(c => ` ${c.status}: ${c.path}`).join('\n'),
98
+ 'GENT_MERGE_DIRTY'
99
+ );
100
+ }
101
+
102
+ const label = theirRevision;
103
+
104
+ if (baseOid === ours && !options.noFastForward) {
105
+ const target = await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(theirs)).tree);
106
+ const current = new Map(index.staged().map(e => [e.path, { mode: e.mode, oid: e.oid }]));
107
+ const plan = await worktree.planCheckout(repo, { from: current, to: target, index });
108
+ await worktree.applyCheckout(repo, plan, { index });
109
+ await index.write(repo.indexPath);
110
+
111
+ if (head.ref) {
112
+ await repo.refs.update(head.ref, theirs, { expectedOldOid: ours, reason: `merge ${label}: Fast-forward` });
113
+ } else {
114
+ await repo.refs.setHeadDetached(theirs, `merge ${label}: Fast-forward`);
115
+ }
116
+ await worktree.completeCheckout(repo);
117
+ return { status: 'fast-forward', oid: theirs, base: baseOid };
118
+ }
119
+
120
+ const baseTree = await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(baseOid)).tree);
121
+ const oursTree = await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(ours)).tree);
122
+ const theirsTree = await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(theirs)).tree);
123
+
124
+ const allPaths = [...new Set([...baseTree.keys(), ...oursTree.keys(), ...theirsTree.keys()])].sort();
125
+ const attributes = new AttributesMatcher(repo);
126
+
127
+ const merged = new Map(); // path -> {mode, oid}
128
+ const conflicts = []; // {path, kind, base, ours, theirs, content?}
129
+
130
+ for (const filePath of allPaths) {
131
+ const base = baseTree.get(filePath) || null;
132
+ const oursEntry = oursTree.get(filePath) || null;
133
+ const theirsEntry = theirsTree.get(filePath) || null;
134
+ const decision = classify(base, oursEntry, theirsEntry);
135
+
136
+ if (decision.kind === 'agree' || decision.kind === 'take-ours' || decision.kind === 'take-theirs') {
137
+ if (decision.entry) merged.set(filePath, decision.entry);
138
+ continue;
139
+ }
140
+
141
+ if (decision.kind === 'modify-delete' || decision.kind === 'mode-conflict') {
142
+ conflicts.push({ path: filePath, kind: decision.kind, base, ours: oursEntry, theirs: theirsEntry });
143
+ if (oursEntry) merged.set(filePath, oursEntry); // keep our side in the worktree
144
+ continue;
145
+ }
146
+
147
+ const baseBytes = base ? await repo.objects.readBlob(base.oid) : Buffer.alloc(0);
148
+ const oursBytes = await repo.objects.readBlob(oursEntry.oid);
149
+ const theirsBytes = await repo.objects.readBlob(theirsEntry.oid);
150
+
151
+ if (looksBinary(oursBytes) || looksBinary(theirsBytes) || looksBinary(baseBytes)) {
152
+ conflicts.push({ path: filePath, kind: 'binary', base, ours: oursEntry, theirs: theirsEntry });
153
+ merged.set(filePath, oursEntry);
154
+ continue;
155
+ }
156
+
157
+ const result = mergeFileContent(
158
+ baseBytes.toString('utf-8'),
159
+ oursBytes.toString('utf-8'),
160
+ theirsBytes.toString('utf-8'),
161
+ filePath
162
+ );
163
+ const content = Buffer.from(result.content, 'utf-8');
164
+ const oid = await repo.objects.write('blob', content);
165
+
166
+ if (result.hasConflicts) {
167
+ conflicts.push({ path: filePath, kind: 'content', base, ours: oursEntry, theirs: theirsEntry, markedOid: oid });
168
+ }
169
+ merged.set(filePath, { mode: oursEntry.mode, oid });
170
+ }
171
+
172
+ // Update the working tree to the merge result, then record the index.
173
+ const current = new Map(index.staged().map(e => [e.path, { mode: e.mode, oid: e.oid }]));
174
+ const plan = await worktree.planCheckout(repo, { from: current, to: merged, index });
175
+ await worktree.applyCheckout(repo, plan, { index });
176
+
177
+ for (const conflict of conflicts) {
178
+ index.remove(conflict.path);
179
+ for (const [stage, side] of [[1, conflict.base], [2, conflict.ours], [3, conflict.theirs]]) {
180
+ if (!side) continue;
181
+ index.addStage(new IndexEntry({ path: conflict.path, oid: side.oid, mode: side.mode, stage }));
182
+ }
183
+ }
184
+ await index.write(repo.indexPath);
185
+
186
+ const message = options.message || `Merge ${label} into ${head.branch || 'HEAD'}`;
187
+
188
+ if (conflicts.length) {
189
+ await ops.writeMergeState(repo, [theirs], `${message}\n\nConflicts:\n${conflicts.map(c => ' ' + c.path).join('\n')}\n`);
190
+ await worktree.completeCheckout(repo);
191
+ return { status: 'conflicts', conflicts, base: baseOid, theirs, ours };
192
+ }
193
+
194
+ const commit = await ops.createCommit(repo, { message, extraParents: [theirs], allowEmpty: true });
195
+ await worktree.completeCheckout(repo);
196
+ return { status: 'merged', oid: commit.oid, base: baseOid, theirs, ours };
197
+ }
198
+
199
+ /**
200
+ * Finish a conflicted merge once every path is resolved in the index.
201
+ * @param {Object} repo
202
+ * @param {String} [message]
203
+ * @returns {Promise<{oid: String}>}
204
+ */
205
+ async function concludeMerge(repo, message) {
206
+ const state = await ops.readMergeState(repo);
207
+ if (!state) throw new MergeError('there is no merge in progress');
208
+
209
+ const index = await GitIndex.read(repo.indexPath);
210
+ if (index.hasConflicts()) {
211
+ throw new MergeError(
212
+ `these paths are still conflicted:\n${[...index.conflicts().keys()].map(p => ' ' + p).join('\n')}`,
213
+ 'GENT_UNMERGED'
214
+ );
215
+ }
216
+
217
+ const commit = await ops.createCommit(repo, {
218
+ message: message || state.message || 'Merge',
219
+ extraParents: state.heads,
220
+ allowEmpty: true
221
+ });
222
+ return commit;
223
+ }
224
+
225
+ /**
226
+ * Throw away an in-progress merge and return to HEAD.
227
+ * @param {Object} repo
228
+ * @returns {Promise<void>}
229
+ */
230
+ async function abortMerge(repo) {
231
+ const state = await ops.readMergeState(repo);
232
+ if (!state) throw new MergeError('there is no merge in progress');
233
+
234
+ await ops.reset(repo, 'hard', 'HEAD');
235
+ await ops.clearMergeState(repo);
236
+ }
237
+
238
+ /**
239
+ * Stage a resolved path from the working tree, clearing its conflict stages.
240
+ * @param {Object} repo
241
+ * @param {String} relativePath
242
+ * @returns {Promise<void>}
243
+ */
244
+ async function markResolved(repo, relativePath) {
245
+ const index = await GitIndex.read(repo.indexPath);
246
+ if (!index.getAll(relativePath).length) {
247
+ throw new MergeError(`'${relativePath}' is not part of this merge`);
248
+ }
249
+ const attributes = new AttributesMatcher(repo);
250
+ const entry = await worktree.stageWorktreeFile(repo, attributes, relativePath);
251
+ index.resolve(entry);
252
+ await index.write(repo.indexPath);
253
+ }
254
+
255
+ /**
256
+ * The three sides of a conflicted path, for an interactive resolver.
257
+ * @param {Object} repo
258
+ * @param {String} relativePath
259
+ * @returns {Promise<{base: Buffer|null, ours: Buffer|null, theirs: Buffer|null}>}
260
+ */
261
+ async function conflictSides(repo, relativePath) {
262
+ const index = await GitIndex.read(repo.indexPath);
263
+ const stages = index.conflicts().get(relativePath);
264
+ if (!stages) throw new MergeError(`'${relativePath}' is not conflicted`);
265
+
266
+ const read = async (entry) => (entry ? repo.objects.readBlob(entry.oid) : null);
267
+ return {
268
+ base: await read(stages.base),
269
+ ours: await read(stages.ours),
270
+ theirs: await read(stages.theirs)
271
+ };
272
+ }
273
+
274
+ module.exports = {
275
+ MergeError,
276
+ merge,
277
+ concludeMerge,
278
+ abortMerge,
279
+ markResolved,
280
+ conflictSides,
281
+ classify
282
+ };
@@ -0,0 +1,257 @@
1
+ /** Recoverable offline migration. Ambiguous legacy state is refused, never guessed. */
2
+ const fs = require('fs').promises;
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const { ObjectStore } = require('./object-store');
6
+ const { GitIndex, IndexEntry } = require('./git-index');
7
+ const { serializeTree, serializeCommit, serializeTag, hashObject, assertObjectId } = require('./git-objects');
8
+ const { assertRefName } = require('./refs');
9
+ const { writeAtomic, readFileOrNull, Lock } = require('./lockfile');
10
+ const { closure, migrationInfo, remoteUrl, nameCheck } = require('./smart-http');
11
+ const repository = require('./repository');
12
+ const { DEFAULT_IGNORE_PATTERNS } = require('./constants');
13
+
14
+ function safeName(name) {
15
+ if (typeof name !== 'string' || !name || name.includes('\\') || name.includes('\0') || name.split('/').some(p => !p || ['.', '..', '.git', '.gent'].includes(p.toLowerCase()))) throw new Error(`unsafe legacy path: ${name}`);
16
+ return name;
17
+ }
18
+ function identity(value, timestamp) {
19
+ const seconds = Math.floor(Date.parse(timestamp) / 1000);
20
+ if (!Number.isSafeInteger(seconds)) throw new Error('legacy timestamp is missing or invalid');
21
+ const name = value?.name || 'Unknown', email = value?.email || '';
22
+ if (/[\n\r\0<>]/.test(name + email)) throw new Error('unsafe legacy identity');
23
+ return { name, email, timestamp: seconds, timezone: '+0000' };
24
+ }
25
+ async function convert(history, readBlob) {
26
+ const incoming = new Map(), mapping = {}, trees = new Map();
27
+ const put = (type, payload) => { const oid = hashObject(type, payload); incoming.set(oid, { oid, type, payload }); return oid; };
28
+ async function tree(entries) {
29
+ const root = new Map(), flat = new Map();
30
+ for (const entry of entries) {
31
+ const name = safeName(entry.name || entry.path), oid = assertObjectId(entry.hash);
32
+ const mode = parseInt(entry.mode || '100644', 8);
33
+ if (entry.type && entry.type !== 'blob' || ![0o100644, 0o100755, 0o120000].includes(mode)) throw new Error('unsupported legacy historical mode/type');
34
+ if (flat.has(name)) throw new Error('duplicate legacy tree path');
35
+ const payload = await readBlob(oid);
36
+ if (hashObject('blob', payload) !== oid) throw new Error(`corrupt legacy blob ${oid}`);
37
+ put('blob', payload); flat.set(name, { path: name, oid, mode, size: payload.length });
38
+ const parts = name.split('/'); let node = root;
39
+ for (const part of parts.slice(0, -1)) {
40
+ if (!node.has(part)) node.set(part, new Map());
41
+ node = node.get(part);
42
+ if (!(node instanceof Map)) throw new Error('legacy file/directory collision');
43
+ }
44
+ if (node.has(parts.at(-1))) throw new Error('legacy file/directory collision');
45
+ node.set(parts.at(-1), { oid, mode });
46
+ }
47
+ const build = node => put('tree', serializeTree([...node].map(([name, item]) => item instanceof Map ? { name, mode: 0o40000, oid: build(item) } : { name, ...item })));
48
+ return { oid: build(root), flat };
49
+ }
50
+ let pending = [...history.commits];
51
+ const allIds = new Set(pending.map(c => c.hash));
52
+ if (allIds.size !== pending.length) throw new Error('duplicate legacy commit IDs');
53
+ while (pending.length) {
54
+ const remaining = [];
55
+ for (const commit of pending) {
56
+ assertObjectId(commit.hash);
57
+ const parents = [commit.parent, commit.mergeParent].filter(Boolean);
58
+ if (parents.some(p => !allIds.has(p))) throw new Error('missing legacy parent');
59
+ if (parents.some(p => !mapping[p])) { remaining.push(commit); continue; }
60
+ const entries = commit.tree || commit.files;
61
+ if (!Array.isArray(entries)) throw new Error('legacy commit has no complete tree snapshot');
62
+ const converted = await tree(entries);
63
+ const author = identity(commit.author, commit.timestamp);
64
+ const oid = put('commit', serializeCommit({ tree: converted.oid, parents: parents.map(p => mapping[p]),
65
+ author, committer: author, message: Buffer.from(commit.message || '') }));
66
+ mapping[commit.hash] = oid; trees.set(commit.hash, converted.flat);
67
+ }
68
+ if (remaining.length === pending.length) throw new Error('cyclic legacy history');
69
+ pending = remaining;
70
+ }
71
+ const refs = new Map();
72
+ for (const [name, old] of Object.entries(history.branches || {})) {
73
+ assertRefName(`refs/heads/${name}`);
74
+ if (old && !mapping[old]) throw new Error('branch names missing legacy commit');
75
+ if (old) refs.set(`refs/heads/${name}`, mapping[old]);
76
+ }
77
+ for (const [name, tag] of Object.entries(history.tags || {})) {
78
+ assertRefName(`refs/tags/${name}`);
79
+ if (!mapping[tag.hash]) throw new Error('tag names missing legacy commit');
80
+ let oid = mapping[tag.hash];
81
+ if (tag.annotated) oid = put('tag', serializeTag({ object: oid, targetType: 'commit', tag: name,
82
+ tagger: identity(tag.tagger, tag.timestamp), message: Buffer.from(tag.message || '') }));
83
+ refs.set(`refs/tags/${name}`, oid);
84
+ }
85
+ const branch = history.currentBranch;
86
+ assertRefName(`refs/heads/${branch}`);
87
+ if (!Object.hasOwn(history.branches, branch)) throw new Error('legacy current branch is missing');
88
+ return { incoming, mapping, refs, branch, flat: trees.get(history.branches[branch]) || new Map() };
89
+ }
90
+ async function digestDirectory(directory) {
91
+ const hash = crypto.createHash('sha256');
92
+ async function walk(dir, prefix = '') {
93
+ for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
94
+ const name = prefix + entry.name;
95
+ if (entry.isSymbolicLink()) throw new Error('symlink in legacy metadata; resolve before migrating');
96
+ hash.update((entry.isDirectory() ? 'dir:' : 'file:') + name + '\0');
97
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), name + '/');
98
+ else hash.update(crypto.createHash('sha256').update(await fs.readFile(path.join(dir, entry.name))).digest());
99
+ }
100
+ }
101
+ await walk(directory); return hash.digest('hex');
102
+ }
103
+ async function plan(root) {
104
+ const source = path.join(root, '.gent');
105
+ if (await readFileOrNull(path.join(root, '.git')) || await fs.lstat(path.join(root, '.git')).then(() => true, () => false)) throw new Error('existing .git metadata must not be overwritten');
106
+ const json = async (name, fallback) => { const raw = await readFileOrNull(path.join(source, name)); return raw ? JSON.parse(raw) : fallback; };
107
+ const history = await json('commits.json', null), config = await json('config.json', {}), staging = await json('staging.json', {});
108
+ if (!history?.commits) throw new Error('run migrate from a legacy repository root');
109
+ const stash = await json('stash.json', {}), journal = await json('journal.json', {});
110
+ if (stash.stack?.length) throw new Error('apply or export legacy stashes before migration; their index/base state cannot be reconstructed safely');
111
+ if (journal.entries?.length || journal.redo?.length) throw new Error('legacy undo history lacks exact index/worktree snapshots; archive journal.json outside the repository before migration if you accept retiring those undo actions');
112
+ if (staging.mergeState) throw new Error('finish or abort the legacy merge before migration');
113
+ const before = await digestDirectory(source);
114
+ const store = new ObjectStore(path.join(source, 'objects'));
115
+ const converted = await convert(history, oid => store.readBlob(oid));
116
+ const flat = new Map(converted.flat);
117
+ const entries = staging.entries || [];
118
+ if ((staging.files || []).some(name => !entries.some(e => e.path === name))) throw new Error('staging has paths without stored blob IDs; re-stage them with v12 first');
119
+ for (const entry of entries) {
120
+ const name = safeName(entry.path);
121
+ if (entry.status === 'deleted') { flat.delete(name); continue; }
122
+ const payload = await store.readBlob(entry.hash);
123
+ if (hashObject('blob', payload) !== entry.hash) throw new Error('corrupt staged blob');
124
+ converted.incoming.set(entry.hash, { oid: entry.hash, type: 'blob', payload });
125
+ flat.set(name, { path: name, oid: entry.hash, mode: converted.flat.get(name)?.mode || 0o100644, size: payload.length });
126
+ }
127
+ const index = new GitIndex();
128
+ for (const entry of flat.values()) index.add(new IndexEntry(entry));
129
+ await closure([...converted.refs].map(([name, oid]) => [oid, name.startsWith('refs/heads/') ? 'commit' : null]), oid => converted.incoming.get(oid));
130
+ if (before !== await digestDirectory(source)) throw new Error('legacy metadata changed during inspection; stop other writers and retry');
131
+ const remotes = {};
132
+ for (const [name, remote] of Object.entries(config.remotes || {})) {
133
+ nameCheck(name);
134
+ const url = new URL(remote.url);
135
+ const match = url.pathname.match(/^(.*)\/api\/repos\/([^/]+)\/([^/]+)\/?$/);
136
+ if (match) url.pathname = `${match[1]}/${match[2]}/${match[3]}.git`;
137
+ remotes[name] = remoteUrl(url.toString());
138
+ const server = await migrationInfo(remotes[name]);
139
+ for (const [old, mapped] of Object.entries(server.mapping || {})) {
140
+ if (converted.mapping[old] && converted.mapping[old] !== mapped) throw new Error(`client/server migration mapping differs for ${old}; retain v12 state for reconciliation`);
141
+ }
142
+ for (const [ref, target] of Object.entries(server.refs || {})) {
143
+ if (ref.startsWith('refs/tags/') && converted.refs.has(ref) && converted.refs.get(ref) !== target) throw new Error(`client/server tag conversion differs for ${ref}; reconcile the original tag metadata first`);
144
+ }
145
+ if (Object.keys(converted.mapping).length && Object.keys(server.mapping || {}).length && !Object.keys(server.mapping).some(old => converted.mapping[old])) throw new Error('no shared migration history with configured server; verify remote ownership');
146
+ }
147
+ return { ...converted, index, before, config, source, remotes };
148
+ }
149
+ function paths(root, id) {
150
+ if (!/^[0-9a-f-]{36}$/.test(id)) throw new Error('invalid migration journal ID');
151
+ const workspace = path.join(path.dirname(root), `.${path.basename(root)}-gent-migration-${id}`);
152
+ return { workspace, source: path.join(root, '.gent'), candidate: path.join(workspace, '.gent'),
153
+ original: path.join(workspace, 'original'), backup: path.join(workspace, 'backup'),
154
+ journal: path.join(root, '.gent-migration.json'), pointer: path.join(root, '.git') };
155
+ }
156
+ async function exists(name) { return fs.lstat(name).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; }); }
157
+ async function recover(root, abort = false) {
158
+ const raw = await readFileOrNull(path.join(root, '.gent-migration.json'));
159
+ let state;
160
+ if (!raw && abort) {
161
+ const marker = await readFileOrNull(path.join(root, '.gent', 'gent', 'migration-pointer.json'));
162
+ if (!marker) throw new Error('no migration to roll back');
163
+ const p = paths(root, JSON.parse(marker).id);
164
+ state = JSON.parse(await fs.readFile(path.join(p.workspace, 'completed.json'), 'utf8'));
165
+ if (await digestDirectory(p.source) !== state.candidate) throw new Error('new canonical state exists; rollback would discard history, preserve it and recover manually');
166
+ await writeAtomic(p.journal, JSON.stringify(state));
167
+ } else if (!raw) throw new Error('no interrupted migration to recover');
168
+ else state = JSON.parse(raw);
169
+ const p = paths(root, state.id);
170
+ if (state.format !== 'gent-migration-1' || await digestDirectory(p.backup) !== state.before) throw new Error('migration backup verification failed; preserve files for manual recovery');
171
+ const pointer = await readFileOrNull(p.pointer);
172
+ if (pointer && pointer.toString() !== 'gitdir: .gent\n') throw new Error('unrecognized .git pointer; refusing recovery');
173
+ const actual = await exists(p.source) ? await digestDirectory(p.source) : null;
174
+ if (actual && actual !== state.before && actual !== state.candidate) throw new Error('repository changed after migration; preserve new history and recover manually');
175
+ if (abort) {
176
+ if (actual === state.candidate) {
177
+ if (await exists(p.candidate)) throw new Error('ambiguous candidate store; refusing recovery');
178
+ await fs.rename(p.source, p.candidate);
179
+ }
180
+ if (!await exists(p.source)) {
181
+ if (await digestDirectory(p.original) !== state.before) throw new Error('original metadata changed');
182
+ await fs.rename(p.original, p.source);
183
+ }
184
+ if (pointer) await fs.unlink(p.pointer);
185
+ await fs.unlink(p.journal);
186
+ return { status: 'rolled back', backup: p.backup };
187
+ }
188
+ if (actual === state.before) {
189
+ if (await exists(p.original)) throw new Error('ambiguous original store; refusing recovery');
190
+ await fs.rename(p.source, p.original);
191
+ }
192
+ if (!await exists(p.source)) {
193
+ if (await digestDirectory(p.candidate) !== state.candidate) throw new Error('candidate metadata changed');
194
+ await fs.rename(p.candidate, p.source);
195
+ }
196
+ if (!pointer) {
197
+ // Lock and compare the pointer so an unrelated concurrent init is never replaced.
198
+ const pointerLock = await Lock.acquire(p.pointer);
199
+ try {
200
+ if (await exists(p.pointer)) throw new Error('.git appeared during migration; refusing overwrite');
201
+ await pointerLock.write('gitdir: .gent\n');
202
+ await pointerLock.commit();
203
+ } finally { await pointerLock.release(); }
204
+ }
205
+ await writeAtomic(path.join(p.workspace, 'completed.json'), JSON.stringify(state));
206
+ await fs.unlink(p.journal);
207
+ return { status: 'migrated', backup: p.backup, mapping: path.join(p.source, 'gent', 'migration.json') };
208
+ }
209
+ async function migrate(root, options = {}) {
210
+ root = await fs.realpath(root);
211
+ if (options.abort && options.continue || options.dryRun && (options.abort || options.continue)) throw new Error('choose one migration operation');
212
+ if (options.abort || options.continue) {
213
+ const lock = await Lock.acquire(path.join(root, '.gent-migrate-operation'));
214
+ try { return await recover(root, Boolean(options.abort)); } finally { await lock.release(); }
215
+ }
216
+ if (await exists(path.join(root, '.gent-migration.json'))) throw new Error('interrupted migration detected; use gent migrate --continue or --abort');
217
+ const value = await plan(root);
218
+ const summary = { format: 'gent-migration-1', commits: Object.keys(value.mapping).length, objects: value.incoming.size,
219
+ refs: Object.fromEntries(value.refs), mapping: value.mapping,
220
+ fallback: 'Historical absent modes become 100644; author timestamp rounded down to seconds, UTC; committer equals author; exact stored message bytes retained.',
221
+ ignore: 'Legacy implicit exclusions become explicit info/exclude rules; .gitignore and .gentignore become visible.' };
222
+ if (options.dryRun) return summary;
223
+ const lock = await Lock.acquire(path.join(root, '.gent-migrate-operation'));
224
+ try {
225
+ if (await exists(path.join(root, '.gent-migration.json'))) throw new Error('another migration started; recover it first');
226
+ const id = crypto.randomUUID(), p = paths(root, id);
227
+ await fs.mkdir(p.workspace);
228
+ await fs.cp(value.source, p.backup, { recursive: true, errorOnExist: true, force: false });
229
+ if (await digestDirectory(p.backup) !== value.before) throw new Error('backup verification failed');
230
+ const { repo } = await repository.init(p.workspace, { defaultBranch: value.branch });
231
+ for (const item of value.incoming.values()) await repo.objects.writeVerified(item.oid, item.type, item.payload);
232
+ for (const [name, oid] of value.refs) await repo.refs.update(name, oid, { expectedOldOid: null, reason: 'migrate legacy history' });
233
+ // Also retain orphaned legacy commits that were not branch or tag tips.
234
+ for (const [old, oid] of Object.entries(value.mapping)) await repo.refs.update(`refs/gent/migration/${old}`, oid, { expectedOldOid: null });
235
+ await require('./worktree').buildTreeFromIndex(repo, value.index);
236
+ await value.index.write(repo.indexPath);
237
+ for (const key of ['name', 'email']) if (value.config.user?.[key]) repo.localConfig.set(`user.${key}`, value.config.user[key]);
238
+ for (const [name, url] of Object.entries(value.remotes)) {
239
+ repo.localConfig.set(`remote.${name}.url`, url);
240
+ repo.localConfig.set(`remote.${name}.fetch`, `+refs/heads/*:refs/remotes/${name}/*`);
241
+ }
242
+ await repo.localConfig.save();
243
+ await writeAtomic(path.join(repo.gentMetaDir, 'migration.json'), JSON.stringify(summary, null, 2));
244
+ await writeAtomic(path.join(repo.gentMetaDir, 'legacy-config.json'), JSON.stringify(value.config, null, 2));
245
+ const gentIgnore = (await readFileOrNull(path.join(root, '.gentignore')))?.toString() || '';
246
+ await writeAtomic(path.join(repo.gitdir, 'info', 'exclude'), DEFAULT_IGNORE_PATTERNS.filter(p => !['.gitignore', '.gentignore'].includes(p)).join('\n') + '\n' + gentIgnore + '\n');
247
+ await closure([...value.refs].map(([name, oid]) => [oid, name.startsWith('refs/heads/') ? 'commit' : null]), oid => repo.objects.read(oid));
248
+ for (const url of Object.values(value.remotes)) await migrationInfo(url);
249
+ if (await exists(p.pointer) || await digestDirectory(value.source) !== value.before) throw new Error('legacy repository changed during migration; active state was preserved');
250
+ await writeAtomic(path.join(repo.gitdir, 'gent', 'migration-pointer.json'), JSON.stringify({ id }));
251
+ const state = { format: 'gent-migration-1', id, before: value.before, candidate: await digestDirectory(repo.gitdir) };
252
+ await writeAtomic(p.journal, JSON.stringify(state));
253
+ if (options.afterPrepare) await options.afterPrepare(); // fault injection for recovery tests
254
+ return await recover(root);
255
+ } finally { await lock.release(); }
256
+ }
257
+ module.exports = { convert, plan, migrate, digestDirectory };