gigarag-claude-code 0.1.1 → 0.2.1

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,76 @@
1
+ /**
2
+ * What a sync does without a model, decided from two maps: what the manifest
3
+ * says was indexed, and what is on disk now. Pure, so every rule here has a
4
+ * test that doesn't need git or a server.
5
+ */
6
+ /** More than this many gone paths, and more than half the manifest, is what a broken checkout looks like. */
7
+ export const MASS_DELETE_MIN = 20;
8
+ function byHash(paths, hashOf) {
9
+ const m = new Map();
10
+ for (const p of paths) {
11
+ const h = hashOf(p);
12
+ m.set(h, [...(m.get(h) ?? []), p]);
13
+ }
14
+ return m;
15
+ }
16
+ export function planSync(input) {
17
+ const { known, current } = input;
18
+ const gone = input.truncated ? [] : [...known.keys()].filter(p => !current.has(p) && !(input.atHead?.has(p) ?? false));
19
+ const added = [...current.keys()].filter(p => !known.has(p));
20
+ // A rename only when the hash is unique on both sides. Two copies of one LICENSE are not evidence.
21
+ const goneByHash = byHash(gone, p => known.get(p).hash);
22
+ const addedByHash = byHash(added, p => current.get(p));
23
+ const renames = [];
24
+ for (const [hash, from] of goneByHash) {
25
+ const to = addedByHash.get(hash);
26
+ if (from.length === 1 && to?.length === 1)
27
+ renames.push({ from: from[0], to: to[0], hash, nodeIds: known.get(from[0]).nodeIds });
28
+ }
29
+ const renamedTo = new Map(renames.map(r => [r.from, r.to]));
30
+ const newPaths = new Set(renames.map(r => r.to));
31
+ const trulyGone = gone.filter(p => !renamedTo.has(p));
32
+ const suspicious = trulyGone.length > MASS_DELETE_MIN && trulyGone.length * 2 > known.size;
33
+ // Which live paths feed each memo once renames are applied.
34
+ const feeders = new Map();
35
+ for (const [path, file] of known) {
36
+ const live = renamedTo.get(path) ?? path;
37
+ for (const id of file.nodeIds)
38
+ feeders.set(id, [...(feeders.get(id) ?? []), live]);
39
+ }
40
+ const goneSet = new Set(trulyGone);
41
+ const deleteNodeIds = new Set();
42
+ const deletedPaths = [];
43
+ const partial = [];
44
+ if (!suspicious || input.allowMassDelete) {
45
+ for (const path of trulyGone) {
46
+ const ids = known.get(path).nodeIds;
47
+ const orphaned = ids.filter(id => (feeders.get(id) ?? []).every(p => goneSet.has(p)));
48
+ for (const id of orphaned)
49
+ deleteNodeIds.add(id);
50
+ const shared = ids.filter(id => !orphaned.includes(id));
51
+ if (shared.length > 0)
52
+ partial.push({ path, nodeIds: shared });
53
+ else
54
+ deletedPaths.push(path);
55
+ }
56
+ }
57
+ const changed = [...current].filter(([p, h]) => known.has(p) && known.get(p).hash !== h).map(([p]) => p);
58
+ const rewrite = [...changed, ...added.filter(p => !newPaths.has(p))].sort();
59
+ const stale = new Set();
60
+ for (const p of changed)
61
+ for (const id of known.get(p).nodeIds)
62
+ stale.add(id);
63
+ for (const p of partial)
64
+ for (const id of p.nodeIds)
65
+ stale.add(id);
66
+ return {
67
+ renames,
68
+ deleteNodeIds: [...deleteNodeIds].sort(),
69
+ deletedPaths: deletedPaths.sort(),
70
+ partial,
71
+ stale: [...stale].sort(),
72
+ rewrite,
73
+ suspicious,
74
+ goneCount: trulyGone.length,
75
+ };
76
+ }
@@ -0,0 +1,72 @@
1
+ const RECORD_BATCH = 500;
2
+ const STALE_BATCH = 200;
3
+ function chunks(items, size) {
4
+ const out = [];
5
+ for (let i = 0; i < items.length; i += size)
6
+ out.push(items.slice(i, i + size));
7
+ return out;
8
+ }
9
+ /** The bucket whose slug is exactly `slug`. list_buckets filters by substring, so the match is checked here. */
10
+ export async function findBucketId(rag, slug) {
11
+ const page = await rag.listBuckets({ q: slug, limit: 50 });
12
+ const hit = page.items.find(b => b['slug'] === slug);
13
+ return typeof hit?.['id'] === 'string' ? hit['id'] : undefined;
14
+ }
15
+ /** repo_sync for one bucket, with core's limits applied by batching rather than by the caller. */
16
+ export class RepoSyncRemote {
17
+ rag;
18
+ bucketId;
19
+ constructor(rag, bucketId) {
20
+ this.rag = rag;
21
+ this.bucketId = bucketId;
22
+ }
23
+ call(action, args = {}) {
24
+ return this.rag.callTool('repo_sync', { bucket_id: this.bucketId, action, ...args });
25
+ }
26
+ state() {
27
+ return this.call('state');
28
+ }
29
+ async manifest() {
30
+ const all = [];
31
+ let after;
32
+ do {
33
+ const page = await this.call('manifest', { limit: 1000, ...(after ? { after } : {}) });
34
+ all.push(...page.items);
35
+ after = page.next_cursor ?? undefined;
36
+ } while (after);
37
+ return all;
38
+ }
39
+ claim(holder, token) {
40
+ return this.call('claim', { holder, ttl_seconds: 600, ...(token ? { token } : {}) });
41
+ }
42
+ /** Never throws: a lease that can't be released lapses on its own in ten minutes. */
43
+ async release(token) {
44
+ try {
45
+ await this.call('release', { token });
46
+ }
47
+ catch {
48
+ /* lapses */
49
+ }
50
+ }
51
+ async record(upserts, removes) {
52
+ const entries = [
53
+ ...upserts.map(u => ({ up: { path: u.path, hash: u.hash, node_ids: u.nodeIds } })),
54
+ ...removes.map(rm => ({ rm })),
55
+ ];
56
+ for (const batch of chunks(entries, RECORD_BATCH)) {
57
+ await this.call('record', {
58
+ upserts: batch.flatMap(e => ('up' in e ? [e.up] : [])),
59
+ removes: batch.flatMap(e => ('rm' in e ? [e.rm] : [])),
60
+ });
61
+ }
62
+ }
63
+ async advance(token, commit, branch) {
64
+ await this.call('advance', { token, commit, ...(branch ? { branch } : {}) });
65
+ }
66
+ async markStale(nodeIds, commit) {
67
+ let marked = 0;
68
+ for (const batch of chunks(nodeIds, STALE_BATCH))
69
+ marked += (await this.call('mark_stale', { node_ids: batch, commit })).marked;
70
+ return marked;
71
+ }
72
+ }
@@ -0,0 +1,125 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { closeSync, existsSync, mkdirSync, openSync } from 'node:fs';
3
+ import { delimiter, dirname, join } from 'node:path';
4
+ /**
5
+ * The GigaRAG MCP tools the gigasync/gigaindex flow actually calls (see
6
+ * plugins/claude-code/agents/gigarag-indexer.md and commands/gigasync.md): find or create the
7
+ * bucket and its threads, write and remove memos, and resolve a link's ref. Nothing here can
8
+ * delete a bucket or thread, link two memos, or read outside what `gigarag scan` already named.
9
+ */
10
+ const GIGARAG_TOOLS = ['list_buckets', 'create_bucket', 'list_threads', 'create_thread', 'find_nodes', 'create_node', 'update_node', 'delete_node'];
11
+ /**
12
+ * Starts Claude Code to rewrite stale memos, with nothing but the GigaRAG tools the flow needs,
13
+ * reading tools and three gigarag subcommands. dontAsk denies anything else rather than waiting
14
+ * for a person who isn't there. Both prefixes are granted per tool, never as `mcp__*__*`, because
15
+ * the plugin and the bare MCP server register the same tool names under different prefixes and an
16
+ * unattended agent should not gain whatever else either server exposes later.
17
+ */
18
+ export const CLAUDE_ARGS = [
19
+ '/gigarag:gigasync',
20
+ '--print',
21
+ '--permission-mode',
22
+ 'dontAsk',
23
+ '--max-turns',
24
+ '300',
25
+ '--output-format',
26
+ 'json',
27
+ '--no-session-persistence',
28
+ '--allowedTools',
29
+ ...GIGARAG_TOOLS.map(t => `mcp__plugin_gigarag_gigarag__${t}`),
30
+ ...GIGARAG_TOOLS.map(t => `mcp__gigarag__${t}`),
31
+ 'Read',
32
+ 'Grep',
33
+ 'Glob',
34
+ 'Agent',
35
+ 'Bash(gigarag scan *)',
36
+ 'Bash(gigarag record *)',
37
+ 'Bash(gigarag sync *)',
38
+ '--disallowedTools',
39
+ 'Write',
40
+ 'Edit',
41
+ 'NotebookEdit',
42
+ 'WebFetch',
43
+ 'WebSearch',
44
+ ];
45
+ /** One cmd.exe command line, every part double-quoted. Refuses what quoting can't make literal. */
46
+ export function cmdLine(parts) {
47
+ for (const p of parts) {
48
+ if (/["%\r\n]/.test(p))
49
+ throw new Error(`An argument for claude holds a character cmd.exe would read as syntax, so the rewrite did not start: ${p}`);
50
+ }
51
+ return parts.map(p => `"${p}"`).join(' ');
52
+ }
53
+ /** GIGARAG_CLAUDE (a path, for tests and odd installs), then claude on PATH, preferring an .exe on Windows. */
54
+ export function findClaude(env, platform) {
55
+ const override = env['GIGARAG_CLAUDE'];
56
+ if (override)
57
+ return existsSync(override) ? override : undefined;
58
+ const names = platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude'];
59
+ for (const dir of (env['PATH'] ?? env['Path'] ?? '').split(delimiter).filter(Boolean)) {
60
+ for (const name of names) {
61
+ const candidate = join(dir, name);
62
+ if (existsSync(candidate))
63
+ return candidate;
64
+ }
65
+ }
66
+ return undefined;
67
+ }
68
+ export function claudeInvocation(path, platform) {
69
+ if (/\.(mjs|js)$/i.test(path))
70
+ return { command: process.execPath, args: [path, ...CLAUDE_ARGS] };
71
+ if (platform === 'win32' && /\.(cmd|bat)$/i.test(path)) {
72
+ return { command: process.env['ComSpec'] ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${cmdLine([path, ...CLAUDE_ARGS])}"`], verbatim: true };
73
+ }
74
+ return { command: path, args: CLAUDE_ARGS };
75
+ }
76
+ function killTree(pid) {
77
+ if (!pid)
78
+ return;
79
+ try {
80
+ if (process.platform === 'win32')
81
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
82
+ else
83
+ process.kill(-pid, 'SIGTERM');
84
+ }
85
+ catch {
86
+ try {
87
+ process.kill(pid, 'SIGKILL');
88
+ }
89
+ catch {
90
+ /* already gone */
91
+ }
92
+ }
93
+ }
94
+ export function runRewriter(inv, cwd, logPath, opts) {
95
+ mkdirSync(dirname(logPath), { recursive: true, mode: 0o700 });
96
+ const fd = openSync(logPath, 'w', 0o600);
97
+ return new Promise(resolvePromise => {
98
+ let settled = false;
99
+ let timedOut = false;
100
+ const child = spawn(inv.command, inv.args, {
101
+ cwd,
102
+ env: opts.env,
103
+ stdio: ['ignore', fd, fd],
104
+ windowsHide: true,
105
+ windowsVerbatimArguments: inv.verbatim ?? false,
106
+ detached: process.platform !== 'win32',
107
+ });
108
+ const tick = setInterval(() => void opts.onTick().catch(() => { }), opts.tickMs);
109
+ const timer = setTimeout(() => {
110
+ timedOut = true;
111
+ killTree(child.pid);
112
+ }, opts.timeoutMs);
113
+ const done = (code) => {
114
+ if (settled)
115
+ return;
116
+ settled = true;
117
+ clearInterval(tick);
118
+ clearTimeout(timer);
119
+ closeSync(fd);
120
+ resolvePromise({ code, timedOut });
121
+ };
122
+ child.on('error', () => done(null));
123
+ child.on('close', code => done(code));
124
+ });
125
+ }
@@ -0,0 +1,30 @@
1
+ import { isTrusted, readBinding } from '../binding.js';
2
+ import { findRepoRoot, localSlug, readOriginUrl, repoInfo, slugFromRemote } from '../scan/repo.js';
3
+ /**
4
+ * Which bucket a repository syncs into. The only file in sync that reads the
5
+ * binding from the sharing levels spec: with one that is trusted, its bucket and workspace;
6
+ * without, the slug from the remote, which is how everything worked before. An untrusted
7
+ * binding's bucket and workspace are both ignored, not just its workspace: reporting
8
+ * `untrustedWorkspace` instead so callers refuse rather than sync under the unreviewed name.
9
+ * Kept free of the SDK so a hook can call it without loading a client.
10
+ *
11
+ * Deviation from the plan: the sharing levels binding is `readBinding(start): { binding, root } | undefined`
12
+ * with `binding.workspace` (a uuid), not `readBinding(root): { workspaceId, bucket } | undefined`. Adapted here.
13
+ */
14
+ export function syncSlug(start) {
15
+ const { root, git } = findRepoRoot(start);
16
+ const found = readBinding(root);
17
+ if (found && !isTrusted(found.root, found.binding.workspace)) {
18
+ // Never `repoInfo(root).slug`: it reads the same binding and would still hand back the
19
+ // untrusted file's own bucket name. Recomputed the way an unbound repository resolves,
20
+ // ignoring the binding entirely, same as `workspaceId` below.
21
+ const remote = git ? readOriginUrl(root) : undefined;
22
+ const slug = (remote && slugFromRemote(remote)) || localSlug(root);
23
+ return { root, git, slug, untrustedWorkspace: found.binding.workspace };
24
+ }
25
+ // Trusted or absent: delegate entirely to repoInfo, which applies the same trust check
26
+ // (`trustedBinding` in binding.ts) rather than re-reading `found.binding.bucket` here, so the
27
+ // two functions cannot drift on what a trusted binding hands back.
28
+ const info = repoInfo(root);
29
+ return { root, git, slug: info.slug, ...(info.workspace ? { workspaceId: info.workspace } : {}) };
30
+ }
@@ -0,0 +1,323 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { hostname } from 'node:os';
3
+ import { scanTree } from '../scan/scan.js';
4
+ import { hooksOptedIn } from '../commands/gitHooks.js';
5
+ import { currentBranch, headCommit, isAncestor, pathsAtHead } from '../git/git.js';
6
+ import { planSync } from './plan.js';
7
+ import { RepoSyncRemote, findBucketId } from './remote.js';
8
+ import { clearNote, readNote } from './notes.js';
9
+ /** How long the worker's local run lock (`sync_run:<slug>`) is honoured before it is assumed stale. */
10
+ export const WORKER_LOCK_MS = 45 * 60_000;
11
+ /**
12
+ * How long an interactive `sync begin` blocks the hook-mode worker for, before the marker is
13
+ * assumed abandoned by a crashed session. Wider than the worker's own lock because the model
14
+ * runs in between `begin` and `end`, off the machine's own clock.
15
+ */
16
+ export const INTERACTIVE_LOCK_MS = 60 * 60_000;
17
+ /**
18
+ * Whether the background worker holds this repository's local run lock right now. The worker and
19
+ * an interactive run share one holder identity (this machine's), so core's lease alone can't tell
20
+ * them apart: it would let an interactive `begin` renew straight over a worker mid-rewrite, and
21
+ * either side's `end` would release a lease the other still needs. This lock is what actually
22
+ * separates them.
23
+ */
24
+ export function workerRunning(state, slug) {
25
+ const startedAt = Number(state.getMeta(`sync_run:${slug}`) ?? 0);
26
+ return startedAt > 0 && Date.now() - startedAt < WORKER_LOCK_MS;
27
+ }
28
+ /**
29
+ * Whether an interactive `sync begin` is in flight for this repository on this machine. Hook mode
30
+ * checks this before touching anything, because `begin` and the hook's worker would otherwise
31
+ * claim core's lease under the same holder name and token key, so the second one in just renews
32
+ * over the first: both stages run against the same manifest and both rewrite the same memos, and
33
+ * whichever side's `finally` runs last releases a lease the other still needs. The marker carries
34
+ * its own TTL, since nothing renews it while the model runs between `begin` and `end`, so a
35
+ * crashed interactive session doesn't block sync forever.
36
+ */
37
+ export function interactiveRunning(state, slug) {
38
+ const startedAt = Number(state.getMeta(`sync_interactive:${slug}`) ?? 0);
39
+ return startedAt > 0 && Date.now() - startedAt < INTERACTIVE_LOCK_MS;
40
+ }
41
+ /** Marks an interactive `sync begin` as in flight. Paired with `clearInteractiveRun`. */
42
+ export function markInteractiveRun(state, slug) {
43
+ state.setMeta(`sync_interactive:${slug}`, String(Date.now()));
44
+ }
45
+ /** Clears the interactive marker, letting hook mode run again. Idempotent. */
46
+ export function clearInteractiveRun(state, slug) {
47
+ state.setMeta(`sync_interactive:${slug}`, '');
48
+ }
49
+ /** `<hostname> <8 characters of a random id kept in state.db>`, in the characters core accepts for a holder. */
50
+ export function holderName(state) {
51
+ let id = state.getMeta('machine_id');
52
+ if (!id) {
53
+ id = randomUUID();
54
+ state.setMeta('machine_id', id);
55
+ }
56
+ const host = hostname().replace(/[^A-Za-z0-9 .:@_-]/g, '-').slice(0, 100) || 'machine';
57
+ return `${host} ${id.slice(0, 8)}`;
58
+ }
59
+ /**
60
+ * The holder name and the state.db key holding the lease token, kept separate per mode so the
61
+ * hook-mode worker never sends the interactive run's cached token to core, and so core's lease
62
+ * itself tells the two apart by holder name if they ever do overlap.
63
+ */
64
+ export function holderFor(state, mode) {
65
+ const base = holderName(state);
66
+ return mode === 'hook' ? `${base} worker` : base;
67
+ }
68
+ /** @internal exported for finishSync and the worker, which must clear the same key they claimed. */
69
+ export function leaseMetaKey(slug, mode) {
70
+ return mode === 'hook' ? `lease:${slug}:worker` : `lease:${slug}`;
71
+ }
72
+ export async function pushUnpushed(state, slug, remote) {
73
+ const rows = state.unpushedFiles(slug);
74
+ if (rows.length === 0)
75
+ return 0;
76
+ await remote.record(rows, []);
77
+ state.markPushed(slug, rows.map(r => r.path));
78
+ return rows.length;
79
+ }
80
+ /**
81
+ * A slug is only unique within a workspace, so the cache of which bucket it resolved to must be
82
+ * too: rebinding this repository to another workspace under the same slug must not reuse the
83
+ * first workspace's id.
84
+ */
85
+ export function bucketCacheKey(target) {
86
+ return `bucket_id:${target.workspaceId ?? 'default'}:${target.slug}`;
87
+ }
88
+ async function bucketIdOf(state, target, opts = {}) {
89
+ const key = bucketCacheKey(target);
90
+ if (!opts.forceRefresh) {
91
+ const cached = state.getMeta(key);
92
+ if (cached)
93
+ return cached;
94
+ }
95
+ const found = await findBucketId(target.rag(), target.slug);
96
+ state.setMeta(key, found ?? '');
97
+ return found;
98
+ }
99
+ /**
100
+ * Core's message for a bucket this key cannot reach, kept in one place so the cache-drop below
101
+ * matches it. core.CLAUDE.md's "Errors are instructions" fix belongs to core's wording; this only
102
+ * has to recognise it, since the MCP SDK strips every field but the message off a thrown error.
103
+ */
104
+ const BUCKET_UNREACHABLE = /cannot reach bucket .+ to sync it/;
105
+ /**
106
+ * A bucket id cached under the right key can still be wrong: the bucket behind it was deleted and
107
+ * recreated with a new id, under the same slug and workspace. Nothing invalidates that on its own,
108
+ * so the first call that finds out drops the cache and resolves once more before giving up, rather
109
+ * than failing bucket_unreachable on every sync from here on.
110
+ */
111
+ async function resolveBucket(state, target, bucketId) {
112
+ try {
113
+ const remote = new RepoSyncRemote(target.rag(), bucketId);
114
+ return { bucketId, remote, remoteState: await remote.state() };
115
+ }
116
+ catch (e) {
117
+ if (!(e instanceof Error) || !BUCKET_UNREACHABLE.test(e.message))
118
+ throw e;
119
+ state.setMeta(bucketCacheKey(target), '');
120
+ const refreshed = await bucketIdOf(state, target, { forceRefresh: true });
121
+ if (!refreshed || refreshed === bucketId)
122
+ throw e;
123
+ const remote = new RepoSyncRemote(target.rag(), refreshed);
124
+ return { bucketId: refreshed, remote, remoteState: await remote.state() };
125
+ }
126
+ }
127
+ /**
128
+ * Everything a sync does that needs no model: guards, lease, the manifest
129
+ * round trip, then renames, deletions and stale marks. Returns 'stop' with a
130
+ * message for a person (interactive) or a note (hook), or 'ok' holding the
131
+ * lease, which the caller must pass to finishSync or release.
132
+ */
133
+ export async function runStage(state, target, opts) {
134
+ const hook = opts.mode === 'hook';
135
+ const slug = target.slug;
136
+ const head = target.git ? headCommit(target.root) : undefined;
137
+ const branch = target.git ? currentBranch(target.root) : undefined;
138
+ // A hook-mode stop that doesn't write its own note (every one below except the ones that
139
+ // return a `note`) must still clear a stale `deferred` note left by an earlier run: otherwise
140
+ // once this run stops for some other reason (not opted in, no bucket, not set up, another
141
+ // branch) that leftover note keeps claiming a sync is queued forever, telling every session
142
+ // that opens meanwhile to wait for a rewrite that will never come, and every commit still spawns
143
+ // a worker whose only job is to discover that and give up silently. It must clear only a
144
+ // `deferred` note, and never any other kind: `needs_approval`, `no_agent`, `dirty` or `failed`
145
+ // can be sitting there from a run on the branch GigaRAG actually syncs, and a silent stop here
146
+ // (a commit made on some other branch in the meantime, say) must not wipe it out from under a
147
+ // developer who later comes back to the branch that note was actually about.
148
+ const stop = (message, note) => {
149
+ if (hook && !note && readNote(state, slug)?.kind === 'deferred')
150
+ clearNote(state, slug);
151
+ return note ? { kind: 'stop', message, note } : { kind: 'stop', message };
152
+ };
153
+ // Gated on this machine's own record of having run `gigarag hooks install` here, never on the
154
+ // bucket's shared `initialised` flag: that flag is the same for every teammate, so a hook file
155
+ // that reaches this machine without this person running install here themselves (checked into
156
+ // git, or copied) must do nothing and touch neither the network nor state.db beyond this read.
157
+ // Checked first, before even the untrusted-binding check below: a machine that never opted in
158
+ // must do nothing and write no note of any kind for a hook-triggered sync, untrusted binding or
159
+ // not. Otherwise a machine that never ran install, on receiving a tracked hook file plus a
160
+ // committed .gigarag.json naming some other workspace (an attacker's, or simply one nobody here
161
+ // reviewed), would still write an `untrusted` note and have SessionStart nudge whoever opens
162
+ // this repository next to run `gigarag trust`, encouraging them to trust a binding on a machine
163
+ // where nothing GigaRAG does was ever asked to run in the first place. Interactive `begin` has
164
+ // no such gate, so its untrusted refusal below still fires first for it.
165
+ if (hook && !hooksOptedIn(target.root, state)) {
166
+ return stop('not opted in on this machine');
167
+ }
168
+ // A binding at this repository's root that has not been trusted (`gigarag use` or `gigarag
169
+ // trust`) must never steer a sync anywhere, checked before anything below touches core: unlike
170
+ // `resolveWorkspace`, which falls back to the machine default and says so, sync has no safe
171
+ // fallback, since the file is committed and read before anyone has reviewed it. Reaching this
172
+ // far with `target.untrustedWorkspace` set would otherwise still resolve and claim a bucket
173
+ // (under the default workspace, since `workspaceId` is unset for this target) rather than
174
+ // refuse outright. See binding.ts and GUIDE.md.
175
+ if (target.untrustedWorkspace) {
176
+ const ws = target.untrustedWorkspace;
177
+ return stop(`${target.root}'s .gigarag.json links this repository to workspace ${ws}, which has not been trusted, so sync did not run and will not until it is. Retrying will not help. If you placed or reviewed this file yourself, run: gigarag trust. To use a different workspace instead: gigarag use <workspace>.`, hook ? { kind: 'untrusted', detail: ws } : undefined);
178
+ }
179
+ // The background worker and an interactive run on this machine claim core's lease under the
180
+ // same holder name, so core alone would let this begin renew straight over a worker mid-rewrite
181
+ // and then release the lease the worker still holds. Step aside instead of racing it.
182
+ if (!hook && workerRunning(state, slug)) {
183
+ return stop(`A background sync is already running for ${slug} on this machine, so starting one here would take over its lease mid-rewrite. It finishes on its own within 45 minutes; run /gigasync again after that, or gigarag sync status to check.`);
184
+ }
185
+ // The reverse of the worker check above: an interactive /gigasync can run for as long as the
186
+ // model takes, off any lock the worker itself holds, so the hook's worker checks the marker
187
+ // /gigasync sets before it does anything else. The `deferred` note tells the worker's own loop
188
+ // (and, failing that, a session that opens meanwhile) that this request is queued, not dropped:
189
+ // the commit that triggered it still gets synced once the marker clears, rather than only the
190
+ // next one to arrive.
191
+ if (hook && interactiveRunning(state, slug)) {
192
+ return stop(`An interactive /gigasync is running for ${slug} on this machine, so the background sync steps aside until it finishes.`, {
193
+ kind: 'deferred',
194
+ });
195
+ }
196
+ const firstBucketId = await bucketIdOf(state, target);
197
+ if (!firstBucketId) {
198
+ return stop(`GigaRAG has no bucket for ${slug} yet, so there is nothing to sync. Run /gigaindex in this repository first.`);
199
+ }
200
+ const { bucketId, remote, remoteState: s } = await resolveBucket(state, target, firstBucketId);
201
+ if (hook && (!s.initialised || !s.branch))
202
+ return stop('not set up for automatic sync');
203
+ if (s.branch && branch !== s.branch && !opts.force) {
204
+ const where = branch ? `branch ${branch}` : 'a detached HEAD';
205
+ return stop(hook
206
+ ? 'another branch'
207
+ : `This checkout is on ${where}, and GigaRAG syncs ${s.branch} for ${slug}. Switch to ${s.branch} and run /gigasync again, or run gigarag sync begin --force to sync this checkout anyway, which rewrites the memos to match it.`);
208
+ }
209
+ // An amend, a rebase (post-rewrite runs this hook) or a --force sync of another branch leaves
210
+ // HEAD holding last_commit's content without HEAD containing it as an ancestor. That is a
211
+ // rewrite, not a regression: last_commit is not an ancestor of HEAD either, so refusing here
212
+ // forever would strand the repository with no way for "git pull" to help, as the old note
213
+ // claimed. Only stop when HEAD is genuinely behind: last_commit is still an ancestor of HEAD
214
+ // (nothing to do, handled above) rules that out, and otherwise this is behind only when HEAD
215
+ // is an ancestor of last_commit, or last_commit is unknown here at all (not fetched or gc'd,
216
+ // where neither direction can be checked). A true divergence proceeds and lets the manifest's
217
+ // content hashes, not commit ancestry, decide what changed.
218
+ if (s.last_commit && head && !opts.force) {
219
+ const containsLastSynced = isAncestor(target.root, s.last_commit);
220
+ const genuinelyBehind = containsLastSynced === undefined || (containsLastSynced === false && isAncestor(target.root, head, s.last_commit) === true);
221
+ if (genuinelyBehind) {
222
+ const short = s.last_commit.slice(0, 7);
223
+ return {
224
+ kind: 'stop',
225
+ note: { kind: 'behind', commit: s.last_commit },
226
+ message: `This checkout doesn't contain ${short}, the commit GigaRAG last synced for ${slug}, so syncing it would rewrite memos to older code. Pull ${s.branch ?? 'the branch'} (git pull) and run /gigasync again, or run gigarag sync begin --force to sync this checkout anyway.`,
227
+ };
228
+ }
229
+ }
230
+ const leaseKey = leaseMetaKey(slug, opts.mode);
231
+ const claim = await remote.claim(holderFor(state, opts.mode), state.getMeta(leaseKey) || undefined);
232
+ if (!claim.granted)
233
+ return { kind: 'stop', note: { kind: 'held' }, message: claim.message };
234
+ state.setMeta(leaseKey, claim.token);
235
+ try {
236
+ await pushUnpushed(state, slug, remote);
237
+ state.replaceManifest(slug, (await remote.manifest()).map(m => ({ path: m.path, hash: m.hash, nodeIds: m.node_ids })));
238
+ const manifest = scanTree(target.root, { state, slug, all: true });
239
+ const plan = planSync({
240
+ known: state.manifestOf(slug),
241
+ current: new Map(manifest.files.map(f => [f.path, f.hash])),
242
+ truncated: manifest.truncated,
243
+ allowMassDelete: !hook && Boolean(opts.force),
244
+ ...(hook && target.git ? { atHead: pathsAtHead(target.root) } : {}),
245
+ });
246
+ if (plan.suspicious && !opts.force) {
247
+ if (!hook) {
248
+ await remote.release(claim.token);
249
+ state.setMeta(leaseKey, '');
250
+ return {
251
+ kind: 'stop',
252
+ note: { kind: 'mass_delete', files: plan.goneCount },
253
+ message: `${plan.goneCount} files in ${slug} look deleted, more than half of what was indexed, so no memos were deleted. If they really are gone, run gigarag sync begin --force to delete their memos. If not, restore the checkout and run /gigasync again.`,
254
+ };
255
+ }
256
+ }
257
+ // A memo a teammate already deleted answers like a forbidden one, so a failed delete is counted, not fatal.
258
+ let deleteErrors = 0;
259
+ for (const id of plan.deleteNodeIds) {
260
+ try {
261
+ await target.rag().deleteNode(id);
262
+ }
263
+ catch {
264
+ deleteErrors++;
265
+ }
266
+ state.deleteNode(id);
267
+ }
268
+ for (const r of plan.renames)
269
+ state.renameFile(slug, r.from, r.to);
270
+ for (const p of plan.deletedPaths)
271
+ state.forgetFile(slug, p);
272
+ await remote.record(plan.renames.map(r => ({ path: r.to, hash: r.hash, nodeIds: r.nodeIds })), [...plan.renames.map(r => r.from), ...plan.deletedPaths]);
273
+ state.markPushed(slug, plan.renames.map(r => r.to));
274
+ if (head && plan.stale.length > 0)
275
+ await remote.markStale(plan.stale, head);
276
+ const message = `Synced the manifest for ${slug}: ${plan.renames.length} renamed, ${plan.deleteNodeIds.length} memos deleted, ` +
277
+ `${plan.stale.length} memos marked stale. ${plan.rewrite.length + plan.partial.length} files are left for the indexer.`;
278
+ return { kind: 'ok', bucketId, token: claim.token, ...(head ? { head } : {}), plan, deleteErrors, message };
279
+ }
280
+ catch (e) {
281
+ await remote.release(claim.token);
282
+ state.setMeta(leaseKey, '');
283
+ throw e;
284
+ }
285
+ }
286
+ /**
287
+ * Push what the indexer recorded, move the last synced commit when nothing is
288
+ * left to do, and release the lease. `init` also sets the branch, which is how
289
+ * /gigaindex opts a repository in.
290
+ */
291
+ export async function finishSync(state, target, opts) {
292
+ const slug = target.slug;
293
+ const leaseKey = leaseMetaKey(slug, opts.mode ?? 'interactive');
294
+ const remote = new RepoSyncRemote(target.rag(), opts.bucketId);
295
+ await pushUnpushed(state, slug, remote);
296
+ const manifest = scanTree(target.root, { state, slug });
297
+ const remaining = manifest.summary.new + manifest.summary.changed + manifest.removed.length;
298
+ const head = target.git ? headCommit(target.root) : undefined;
299
+ // An --init that could not advance (the indexer left files) is remembered, so the next end that
300
+ // does advance also records the branch. Without this the repository never opts in.
301
+ const initPending = Boolean(opts.init) || state.getMeta(`sync_init:${slug}`) === '1';
302
+ const branch = initPending && target.git ? currentBranch(target.root) : undefined;
303
+ let advanced = false;
304
+ try {
305
+ if (remaining === 0 && head && opts.token) {
306
+ await remote.advance(opts.token, head, branch);
307
+ advanced = true;
308
+ clearNote(state, slug);
309
+ }
310
+ state.setMeta(`sync_init:${slug}`, initPending && !advanced ? '1' : '');
311
+ }
312
+ finally {
313
+ if (opts.token)
314
+ await remote.release(opts.token);
315
+ state.setMeta(leaseKey, '');
316
+ }
317
+ const message = advanced
318
+ ? `GigaRAG is in sync with ${slug} at ${head.slice(0, 7)}${branch ? ` on ${branch}` : ''}.`
319
+ : remaining > 0
320
+ ? `${remaining} files in ${slug} are still new, changed or gone, so the last synced commit stays where it was. Run /gigasync to finish.`
321
+ : `${slug} is not a git checkout, so there is no commit to record. The manifest is saved.`;
322
+ return { advanced, remaining, message };
323
+ }
@@ -0,0 +1,14 @@
1
+ import { GigaRag } from '../sdk.js';
2
+ import { syncSlug } from './slug.js';
3
+ export function syncTarget(start) {
4
+ const { root, git, slug, workspaceId, untrustedWorkspace } = syncSlug(start);
5
+ let client;
6
+ return {
7
+ root,
8
+ slug,
9
+ git,
10
+ ...(workspaceId ? { workspaceId } : {}),
11
+ ...(untrustedWorkspace ? { untrustedWorkspace } : {}),
12
+ rag: () => (client ??= new GigaRag({ actor: 'agent:gigarag-sync', ...(workspaceId ? { workspaceId } : {}) })),
13
+ };
14
+ }