contextgit 0.0.1 → 0.0.2

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.
Files changed (83) hide show
  1. package/.claude/settings.local.json +41 -0
  2. package/.contextgit/config.json +10 -0
  3. package/.contextgit/system-prompt.md +4 -0
  4. package/.github/workflows/contextgit-ci.yml +40 -0
  5. package/CLAUDE.md +123 -0
  6. package/CLAUDE.md.next +65 -0
  7. package/docs/ContextGit_ARCHITECTURE_v3.md +1141 -0
  8. package/docs/ContextGit_DELTA.md +84 -0
  9. package/docs/ContextGit_PHASE1_PLAN.md +177 -0
  10. package/docs/ContextGit_PHASE2_PLAN.md +535 -0
  11. package/docs/ContextGit_PRD_v4.md +488 -0
  12. package/docs/decisions.md +370 -0
  13. package/package.json +23 -8
  14. package/packages/api/package.json +25 -0
  15. package/packages/api/src/bootstrap.ts +64 -0
  16. package/packages/api/src/config.ts +45 -0
  17. package/packages/api/src/index.ts +17 -0
  18. package/packages/api/src/middleware/auth.test.ts +83 -0
  19. package/packages/api/src/middleware/auth.ts +41 -0
  20. package/packages/api/src/remote-store.test.ts +301 -0
  21. package/packages/api/src/router.ts +121 -0
  22. package/packages/api/src/server-config.ts +34 -0
  23. package/packages/api/src/server.ts +38 -0
  24. package/packages/api/src/store-router.ts +241 -0
  25. package/packages/api/tsconfig.json +8 -0
  26. package/packages/cli/bin/run.js +4 -0
  27. package/packages/cli/package.json +29 -0
  28. package/packages/cli/src/bootstrap.ts +68 -0
  29. package/packages/cli/src/commands/branch.ts +58 -0
  30. package/packages/cli/src/commands/claim.ts +58 -0
  31. package/packages/cli/src/commands/commit.ts +79 -0
  32. package/packages/cli/src/commands/context.ts +46 -0
  33. package/packages/cli/src/commands/doctor.ts +99 -0
  34. package/packages/cli/src/commands/init.ts +141 -0
  35. package/packages/cli/src/commands/keygen.ts +65 -0
  36. package/packages/cli/src/commands/log.ts +103 -0
  37. package/packages/cli/src/commands/merge.ts +36 -0
  38. package/packages/cli/src/commands/pull.ts +145 -0
  39. package/packages/cli/src/commands/push.ts +158 -0
  40. package/packages/cli/src/commands/remote-show.ts +87 -0
  41. package/packages/cli/src/commands/search.ts +54 -0
  42. package/packages/cli/src/commands/serve.ts +61 -0
  43. package/packages/cli/src/commands/set-remote.ts +30 -0
  44. package/packages/cli/src/commands/status.ts +62 -0
  45. package/packages/cli/src/commands/unclaim.ts +28 -0
  46. package/packages/cli/src/config.ts +64 -0
  47. package/packages/cli/src/git-hooks.ts +61 -0
  48. package/packages/cli/tsconfig.json +9 -0
  49. package/packages/core/package.json +28 -0
  50. package/packages/core/src/embeddings.test.ts +58 -0
  51. package/packages/core/src/embeddings.ts +75 -0
  52. package/packages/core/src/engine.ts +274 -0
  53. package/packages/core/src/index.ts +6 -0
  54. package/packages/core/src/snapshot.ts +82 -0
  55. package/packages/core/src/summarizer.test.ts +120 -0
  56. package/packages/core/src/summarizer.ts +113 -0
  57. package/packages/core/src/threads.ts +29 -0
  58. package/packages/core/src/types.ts +240 -0
  59. package/packages/core/tsconfig.json +9 -0
  60. package/packages/mcp/package.json +31 -0
  61. package/packages/mcp/src/auto-snapshot.ts +83 -0
  62. package/packages/mcp/src/config.ts +53 -0
  63. package/packages/mcp/src/git-sync.ts +94 -0
  64. package/packages/mcp/src/index.ts +19 -0
  65. package/packages/mcp/src/server.ts +377 -0
  66. package/packages/mcp/tsconfig.json +9 -0
  67. package/packages/store/package.json +30 -0
  68. package/packages/store/src/branch-merge.test.ts +127 -0
  69. package/packages/store/src/engine-integration.test.ts +93 -0
  70. package/packages/store/src/index.ts +3 -0
  71. package/packages/store/src/interface.ts +62 -0
  72. package/packages/store/src/local/claims.test.ts +190 -0
  73. package/packages/store/src/local/index.ts +380 -0
  74. package/packages/store/src/local/local-store.test.ts +164 -0
  75. package/packages/store/src/local/migrations.ts +99 -0
  76. package/packages/store/src/local/queries.ts +760 -0
  77. package/packages/store/src/local/schema.ts +157 -0
  78. package/packages/store/src/remote/index.ts +300 -0
  79. package/packages/store/tsconfig.json +9 -0
  80. package/pnpm-workspace.yaml +2 -0
  81. package/scripts/build.sh +28 -0
  82. package/tsconfig.base.json +14 -0
  83. package/vitest.config.ts +15 -0
@@ -0,0 +1,99 @@
1
+ // doctor — diagnose the ContextGit installation and report issues.
2
+
3
+ import os from 'os'
4
+ import { join, resolve } from 'path'
5
+ import { existsSync, readFileSync } from 'fs'
6
+ import { Command } from '@oclif/core'
7
+ import { LocalStore } from '@contextgit/store'
8
+ import { findConfigPath } from '../config.js'
9
+
10
+ const SENTINEL = '# contextgit'
11
+
12
+ export default class DoctorCmd extends Command {
13
+ static description = 'Check ContextGit setup and diagnose issues'
14
+
15
+ async run(): Promise<void> {
16
+ this.log('ContextGit Doctor\n')
17
+
18
+ let passed = 0
19
+ let failed = 0
20
+
21
+ const check = (label: string, ok: boolean, hint?: string) => {
22
+ const icon = ok ? '✓' : '✗'
23
+ this.log(` [${icon}] ${label}`)
24
+ if (!ok && hint) this.log(` → ${hint}`)
25
+ ok ? passed++ : failed++
26
+ }
27
+
28
+ // ── 1. Config file ────────────────────────────────────────────────────────
29
+ let configPath: string | null = null
30
+ let config: Record<string, unknown> | null = null
31
+ try {
32
+ configPath = findConfigPath(process.cwd())
33
+ config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>
34
+ check('Config file found and valid JSON', true)
35
+ } catch {
36
+ check('Config file found and valid JSON', false, 'Run: contextgit init')
37
+ }
38
+
39
+ // ── 2. DB reachable ───────────────────────────────────────────────────────
40
+ if (config?.projectId) {
41
+ try {
42
+ const store = new LocalStore(config.projectId as string)
43
+ await store.getProject(config.projectId as string)
44
+ check('Local DB reachable', true)
45
+ } catch {
46
+ check('Local DB reachable', false, 'DB file may be corrupted — try: contextgit init')
47
+ }
48
+ } else {
49
+ check('Local DB reachable', false, 'No config to check DB against')
50
+ }
51
+
52
+ // ── 3. Git hooks installed ────────────────────────────────────────────────
53
+ const gitHooksDir = resolve(process.cwd(), '.git', 'hooks')
54
+ const postCommitPath = join(gitHooksDir, 'post-commit')
55
+ const hooksInstalled = existsSync(postCommitPath) &&
56
+ readFileSync(postCommitPath, 'utf-8').includes(SENTINEL)
57
+ check(
58
+ 'Git hooks installed',
59
+ hooksInstalled,
60
+ 'Run: contextgit init --hooks',
61
+ )
62
+
63
+ // ── 4. API key configured (only relevant if remote is set) ────────────────
64
+ const remote = config?.remote as string | undefined
65
+ if (remote) {
66
+ const serverCfgPath = join(os.homedir(), '.contextgit', 'server.json')
67
+ let hasKey = false
68
+ try {
69
+ const cfg = JSON.parse(readFileSync(serverCfgPath, 'utf-8')) as Record<string, unknown>
70
+ hasKey = typeof cfg['keyHash'] === 'string' && cfg['keyHash'].length > 0
71
+ } catch { /* not configured */ }
72
+ check(
73
+ `API key configured (remote: ${remote})`,
74
+ hasKey,
75
+ 'Run: contextgit keygen --save',
76
+ )
77
+ } else {
78
+ check('API key (no remote configured — skipped)', true)
79
+ }
80
+
81
+ // ── 5. MCP registered in ~/.claude.json ───────────────────────────────────
82
+ const claudeJsonPath = join(os.homedir(), '.claude.json')
83
+ let mcpRegistered = false
84
+ try {
85
+ const raw = readFileSync(claudeJsonPath, 'utf-8')
86
+ mcpRegistered = raw.includes('contextgit')
87
+ } catch { /* file missing */ }
88
+ check(
89
+ 'MCP server registered in ~/.claude.json',
90
+ mcpRegistered,
91
+ 'Add contextgit to mcpServers in ~/.claude.json',
92
+ )
93
+
94
+ // ── Summary ───────────────────────────────────────────────────────────────
95
+ this.log('')
96
+ this.log(`${passed} passed, ${failed} failed`)
97
+ if (failed > 0) process.exitCode = 1
98
+ }
99
+ }
@@ -0,0 +1,141 @@
1
+ // init — create .contextgit/config.json + project + branch in LocalStore.
2
+
3
+ import { Command, Flags } from '@oclif/core'
4
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'fs'
5
+ import { join, basename } from 'path'
6
+ import { nanoid } from 'nanoid'
7
+ import { simpleGit } from 'simple-git'
8
+ import { LocalStore } from '@contextgit/store'
9
+ import type { ContextGitConfig } from '@contextgit/core'
10
+ import { installGitHooks } from '../git-hooks.js'
11
+
12
+ const SYSTEM_PROMPT_FRAGMENT = `\
13
+ You have access to ContextGit memory tools. At the start of every session, call
14
+ context_get with scope=global to load project state. After completing significant
15
+ work, call context_commit with a message describing what was done and any open
16
+ threads. Use context_branch before exploring risky changes.
17
+ `
18
+
19
+ export default class Init extends Command {
20
+ static description = 'Initialize ContextGit in this project'
21
+
22
+ static flags = {
23
+ name: Flags.string({
24
+ char: 'n',
25
+ description: 'Project name (defaults to current directory name)',
26
+ required: false,
27
+ }),
28
+ hooks: Flags.boolean({
29
+ description: 'Install git hooks to auto-capture context on every git commit',
30
+ default: false,
31
+ }),
32
+ }
33
+
34
+ async run(): Promise<void> {
35
+ const { flags } = await this.parse(Init)
36
+ const cwd = process.cwd()
37
+ const configDir = join(cwd, '.contextgit')
38
+ const configPath = join(configDir, 'config.json')
39
+ const promptPath = join(configDir, 'system-prompt.md')
40
+
41
+ // ── Self-heal: config exists but DB may be empty ───────────────────────────
42
+ if (existsSync(configPath)) {
43
+ let existing: ContextGitConfig
44
+ try {
45
+ existing = JSON.parse(readFileSync(configPath, 'utf8')) as ContextGitConfig
46
+ } catch {
47
+ this.error('Found .contextgit/config.json but could not parse it. Delete it and re-run init.')
48
+ }
49
+
50
+ const store = new LocalStore(existing.projectId)
51
+ const gitBranch = await detectGitBranch(cwd)
52
+ const branch = await store.getBranchByGitName(existing.projectId, gitBranch)
53
+
54
+ if (branch) {
55
+ this.log('ContextGit already initialized. Config found at .contextgit/config.json')
56
+ return
57
+ }
58
+
59
+ // DB missing or empty — recreate project + branch
60
+ this.log('Config found but DB is empty — recreating project and branch in DB.')
61
+ await store.createProject({ id: existing.projectId, name: existing.project })
62
+ await store.createBranch({
63
+ projectId: existing.projectId,
64
+ name: `Context: ${gitBranch}`,
65
+ gitBranch,
66
+ })
67
+ writeSystemPrompt(promptPath)
68
+ this.log(`Recreated project "${existing.project}" (${existing.projectId}) for branch: ${gitBranch}`)
69
+ this.log(`System prompt: .contextgit/system-prompt.md`)
70
+ this.log(SYSTEM_PROMPT_FRAGMENT)
71
+ if (flags.hooks) {
72
+ installGitHooks(cwd)
73
+ this.log('Git hooks installed (.git/hooks/post-commit, post-checkout, post-merge)')
74
+ }
75
+ return
76
+ }
77
+
78
+ // ── Fresh init ─────────────────────────────────────────────────────────────
79
+ const projectName = flags.name ?? basename(cwd)
80
+ const projectId = nanoid()
81
+
82
+ // Open store (creates DB + runs migrations).
83
+ const store = new LocalStore(projectId)
84
+ await store.createProject({ id: projectId, name: projectName })
85
+
86
+ const gitBranch = await detectGitBranch(cwd)
87
+
88
+ await store.createBranch({
89
+ projectId,
90
+ name: `Context: ${gitBranch}`,
91
+ gitBranch,
92
+ })
93
+
94
+ // Write config
95
+ mkdirSync(configDir, { recursive: true })
96
+ const config: ContextGitConfig = {
97
+ project: projectName,
98
+ projectId,
99
+ store: 'local',
100
+ agentRole: 'solo',
101
+ workflowType: 'interactive',
102
+ autoSnapshot: false,
103
+ snapshotInterval: 10,
104
+ embeddingModel: 'local',
105
+ }
106
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
107
+ writeSystemPrompt(promptPath)
108
+
109
+ this.log(`Initialized ContextGit for project: ${projectName}`)
110
+ this.log(`Project ID: ${projectId}`)
111
+ this.log(`Branch: ${gitBranch}`)
112
+ this.log(`Config: .contextgit/config.json`)
113
+ this.log(`DB: ~/.contextgit/projects/${projectId}.db`)
114
+ this.log(``)
115
+ if (flags.hooks) {
116
+ installGitHooks(cwd)
117
+ this.log(`Git hooks installed (.git/hooks/post-commit, post-checkout, post-merge)`)
118
+ } else {
119
+ this.log(`Tip: run "contextgit init --hooks" to auto-capture context on every git commit`)
120
+ }
121
+ this.log(``)
122
+ this.log(`Add the following to your MCP system prompt (.contextgit/system-prompt.md):`)
123
+ this.log(``)
124
+ this.log(SYSTEM_PROMPT_FRAGMENT)
125
+ }
126
+ }
127
+
128
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
129
+
130
+ async function detectGitBranch(cwd: string): Promise<string> {
131
+ try {
132
+ const git = simpleGit(cwd)
133
+ return (await git.revparse(['--abbrev-ref', 'HEAD'])).trim()
134
+ } catch {
135
+ return 'main'
136
+ }
137
+ }
138
+
139
+ function writeSystemPrompt(promptPath: string): void {
140
+ writeFileSync(promptPath, SYSTEM_PROMPT_FRAGMENT)
141
+ }
@@ -0,0 +1,65 @@
1
+ // keygen — generate an API key and optionally save its hash to ~/.contextgit/server.json.
2
+ //
3
+ // The plaintext key is shown once and never written to disk. Only the SHA-256
4
+ // hash is persisted so the server can verify Bearer tokens without storing secrets.
5
+
6
+ import os from 'os'
7
+ import { join } from 'path'
8
+ import { createHash } from 'crypto'
9
+ import { mkdirSync, readFileSync, writeFileSync } from 'fs'
10
+ import { Command, Flags } from '@oclif/core'
11
+ import { nanoid } from 'nanoid'
12
+
13
+ const CONTEXTGIT_DIR = join(os.homedir(), '.contextgit')
14
+ const SERVER_CONFIG_PATH = join(CONTEXTGIT_DIR, 'server.json')
15
+
16
+ function sha256hex(input: string): string {
17
+ return createHash('sha256').update(input, 'utf8').digest('hex')
18
+ }
19
+
20
+ function readServerConfig(): Record<string, unknown> {
21
+ try {
22
+ return JSON.parse(readFileSync(SERVER_CONFIG_PATH, 'utf-8')) as Record<string, unknown>
23
+ } catch {
24
+ return {}
25
+ }
26
+ }
27
+
28
+ function saveKeyHash(hash: string): void {
29
+ mkdirSync(CONTEXTGIT_DIR, { recursive: true })
30
+ const cfg = { ...readServerConfig(), keyHash: hash }
31
+ writeFileSync(SERVER_CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 })
32
+ }
33
+
34
+ export default class KeygenCmd extends Command {
35
+ static description = 'Generate an API key for securing the ContextGit API server'
36
+
37
+ static flags = {
38
+ save: Flags.boolean({
39
+ description: 'Save the key hash to ~/.contextgit/server.json (key shown once, never stored)',
40
+ default: false,
41
+ }),
42
+ length: Flags.integer({
43
+ description: 'Key length (default 32)',
44
+ default: 32,
45
+ }),
46
+ }
47
+
48
+ async run(): Promise<void> {
49
+ const { flags } = await this.parse(KeygenCmd)
50
+
51
+ const key = nanoid(flags.length)
52
+
53
+ this.log(`API Key: ${key}`)
54
+ this.log(`(Copy this now — it will not be stored anywhere in plaintext)`)
55
+
56
+ if (flags.save) {
57
+ saveKeyHash(sha256hex(key))
58
+ this.log(`\nKey hash saved to ~/.contextgit/server.json`)
59
+ this.log(`Set this key in the Authorization header when calling the API:`)
60
+ this.log(` Authorization: Bearer ${key}`)
61
+ } else {
62
+ this.log(`\nTo activate this key, re-run with --save`)
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,103 @@
1
+ // log — list context commits with formatting.
2
+
3
+ import { Command, Flags } from '@oclif/core'
4
+ import { simpleGit } from 'simple-git'
5
+ import { LocalStore } from '@contextgit/store'
6
+ import { loadConfig } from '../config.js'
7
+
8
+ export default class LogCmd extends Command {
9
+ static description = 'List context commits for the current branch'
10
+
11
+ static flags = {
12
+ limit: Flags.integer({
13
+ char: 'n',
14
+ description: 'Max commits to show',
15
+ default: 20,
16
+ }),
17
+ all: Flags.boolean({
18
+ char: 'a',
19
+ description: 'Show commits across all branches',
20
+ default: false,
21
+ }),
22
+ branch: Flags.string({
23
+ char: 'b',
24
+ description: 'Show commits for a specific git branch name',
25
+ }),
26
+ verbose: Flags.boolean({
27
+ char: 'v',
28
+ description: 'Show full commit content',
29
+ default: false,
30
+ }),
31
+ }
32
+
33
+ async run(): Promise<void> {
34
+ const { flags } = await this.parse(LogCmd)
35
+ const config = loadConfig()
36
+ const store = new LocalStore(config.projectId)
37
+ const cwd = process.cwd()
38
+
39
+ let gitBranch = 'main'
40
+ try {
41
+ gitBranch = (await simpleGit(cwd).revparse(['--abbrev-ref', 'HEAD'])).trim()
42
+ } catch {
43
+ // fallback
44
+ }
45
+
46
+ if (flags.all) {
47
+ const branches = await store.listBranches(config.projectId)
48
+ if (branches.length === 0) {
49
+ this.log('No branches found. Run `contextgit init` first.')
50
+ return
51
+ }
52
+
53
+ for (const branch of branches) {
54
+ const commits = await store.listCommits(branch.id, { limit: flags.limit, offset: 0 })
55
+ if (commits.length === 0) continue
56
+
57
+ this.log(`\n── ${branch.name} (${branch.gitBranch}) ──`)
58
+ this.printCommits(commits, flags.verbose)
59
+ }
60
+ } else {
61
+ const targetGitBranch = flags.branch ?? gitBranch
62
+ const branch = await store.getBranchByGitName(config.projectId, targetGitBranch)
63
+ if (!branch) {
64
+ this.error(
65
+ `No context branch for git branch '${targetGitBranch}'. Run 'contextgit init' first.`,
66
+ )
67
+ }
68
+
69
+ const commits = await store.listCommits(branch.id, { limit: flags.limit, offset: 0 })
70
+ if (commits.length === 0) {
71
+ this.log(`No commits on branch '${branch.name}'.`)
72
+ return
73
+ }
74
+
75
+ this.log(`Branch: ${branch.name} [${branch.gitBranch}]`)
76
+ this.log(`Showing ${commits.length} commit(s)\n`)
77
+ this.printCommits(commits, flags.verbose)
78
+ }
79
+ }
80
+
81
+ private printCommits(
82
+ commits: import('@contextgit/core').Commit[],
83
+ verbose: boolean,
84
+ ): void {
85
+ for (const c of commits) {
86
+ const ts = new Date(c.createdAt).toLocaleString()
87
+ const sha = c.gitCommitSha ? ` git:${c.gitCommitSha.slice(0, 7)}` : ''
88
+ const agent = c.agentId ? ` [${c.agentId}]` : ''
89
+ this.log(`commit ${c.id}`)
90
+ this.log(`Date: ${ts}${sha}${agent}`)
91
+ this.log(``)
92
+ this.log(` ${c.message}`)
93
+ if (verbose && c.content && c.content !== c.message) {
94
+ this.log(``)
95
+ const lines = c.content.split('\n')
96
+ for (const line of lines) {
97
+ this.log(` ${line}`)
98
+ }
99
+ }
100
+ this.log(``)
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,36 @@
1
+ // merge — merge a context branch into the current branch.
2
+
3
+ import { Command, Args, Flags } from '@oclif/core'
4
+ import { bootstrap } from '../bootstrap.js'
5
+
6
+ export default class MergeCmd extends Command {
7
+ static description = 'Merge a context branch into the current branch'
8
+
9
+ static args = {
10
+ sourceBranchId: Args.string({
11
+ description: 'ID of the source branch to merge from',
12
+ required: true,
13
+ }),
14
+ }
15
+
16
+ static flags = {
17
+ summary: Flags.string({
18
+ char: 's',
19
+ description: 'Summary of what was merged (defaults to a generic message)',
20
+ required: false,
21
+ }),
22
+ }
23
+
24
+ async run(): Promise<void> {
25
+ const { args, flags } = await this.parse(MergeCmd)
26
+ const ctx = await bootstrap()
27
+
28
+ const summary = flags.summary ?? `Merged branch ${args.sourceBranchId} into current branch`
29
+
30
+ const mergeCommit = await ctx.store.mergeBranch(args.sourceBranchId, ctx.branchId, summary)
31
+
32
+ this.log(`Branch merged.`)
33
+ this.log(`Merge commit ID: ${mergeCommit.id}`)
34
+ this.log(`Summary: ${mergeCommit.summary}`)
35
+ }
36
+ }
@@ -0,0 +1,145 @@
1
+ // pull — pull context commits from a remote ContextGit API server into local store.
2
+ //
3
+ // Requires `remote` in .contextgit/config.json.
4
+ // Algorithm:
5
+ // 1. Ensure project exists locally (by ID — already initialized)
6
+ // 2. For each remote branch: ensure branch exists locally
7
+ // 3. For each branch: collect remote commits not present locally, write them
8
+
9
+ import { Command, Flags } from '@oclif/core'
10
+ import { LocalStore, RemoteStore } from '@contextgit/store'
11
+ import type { Pagination } from '@contextgit/core'
12
+ import { loadConfig } from '../config.js'
13
+
14
+ const PAGE = 100
15
+
16
+ async function allCommits(
17
+ store: LocalStore | RemoteStore,
18
+ branchId: string,
19
+ ): Promise<import('@contextgit/core').Commit[]> {
20
+ const acc: import('@contextgit/core').Commit[] = []
21
+ let offset = 0
22
+ const pg: Pagination = { limit: PAGE, offset }
23
+ while (true) {
24
+ pg.offset = offset
25
+ const page = await store.listCommits(branchId, pg)
26
+ acc.push(...page)
27
+ if (page.length < PAGE) break
28
+ offset += PAGE
29
+ }
30
+ return acc
31
+ }
32
+
33
+ export default class PullCmd extends Command {
34
+ static description = 'Pull context commits from a remote ContextGit API'
35
+
36
+ static flags = {
37
+ remote: Flags.string({
38
+ char: 'r',
39
+ description: 'Remote API URL (overrides config.remote)',
40
+ required: false,
41
+ }),
42
+ 'dry-run': Flags.boolean({
43
+ description: 'Show what would be pulled without writing locally',
44
+ default: false,
45
+ }),
46
+ }
47
+
48
+ async run(): Promise<void> {
49
+ const { flags } = await this.parse(PullCmd)
50
+ const config = loadConfig()
51
+
52
+ const remoteUrl = flags.remote ?? config.remote
53
+ if (!remoteUrl) {
54
+ this.error(
55
+ 'No remote configured. Add "remote": "<url>" to .contextgit/config.json or pass --remote.',
56
+ )
57
+ }
58
+
59
+ const local = new LocalStore(config.projectId)
60
+ const remote = new RemoteStore(remoteUrl)
61
+ const dryRun = flags['dry-run']
62
+
63
+ // Verify project exists on remote
64
+ const remoteProject = await remote.getProject(config.projectId).catch(() => null)
65
+ if (!remoteProject) {
66
+ this.error(`Project ${config.projectId} not found on remote ${remoteUrl}. Push first.`)
67
+ }
68
+
69
+ // List all remote branches
70
+ const remoteBranches = await remote.listBranches(config.projectId)
71
+ let totalPulled = 0
72
+
73
+ for (const branch of remoteBranches) {
74
+ // Ensure branch exists locally
75
+ const localBranch = await local.getBranch(branch.id).catch(() => null)
76
+ if (!localBranch) {
77
+ if (!dryRun) {
78
+ await local.createBranch({
79
+ id: branch.id,
80
+ projectId: branch.projectId,
81
+ name: branch.name,
82
+ gitBranch: branch.gitBranch,
83
+ parentBranchId: branch.parentBranchId,
84
+ })
85
+ }
86
+ this.log(`[branch] ${dryRun ? 'would create' : 'created'}: ${branch.name}`)
87
+ }
88
+
89
+ // Collect local commit IDs for this branch
90
+ const localCommits = await allCommits(local, branch.id)
91
+ const localCommitIds = new Set(localCommits.map(c => c.id))
92
+
93
+ // Pull missing commits from remote
94
+ const remoteCommits = await allCommits(remote, branch.id)
95
+ const missing = remoteCommits.filter(c => !localCommitIds.has(c.id))
96
+
97
+ if (missing.length === 0) {
98
+ this.log(`[branch] ${branch.name}: up to date (${remoteCommits.length} commits)`)
99
+ continue
100
+ }
101
+
102
+ this.log(`[branch] ${branch.name}: pulling ${missing.length} commit(s)…`)
103
+
104
+ for (const commit of missing) {
105
+ if (!dryRun) {
106
+ await local.createCommit({
107
+ id: commit.id,
108
+ branchId: commit.branchId,
109
+ parentId: commit.parentId,
110
+ agentId: commit.agentId,
111
+ agentRole: commit.agentRole,
112
+ tool: commit.tool,
113
+ workflowType: commit.workflowType,
114
+ loopIteration: commit.loopIteration,
115
+ ciRunId: commit.ciRunId,
116
+ pipelineName: commit.pipelineName,
117
+ message: commit.message,
118
+ content: commit.content,
119
+ summary: commit.summary,
120
+ commitType: commit.commitType,
121
+ gitCommitSha: commit.gitCommitSha,
122
+ })
123
+ }
124
+ totalPulled++
125
+ }
126
+ }
127
+
128
+ // Thread sync: pull open threads that are missing locally
129
+ const remoteThreads = await remote.listOpenThreads(config.projectId)
130
+ const localThreads = await local.listOpenThreads(config.projectId)
131
+ const localThreadIds = new Set(localThreads.map(t => t.id))
132
+ const missingThreads = remoteThreads.filter(t => !localThreadIds.has(t.id))
133
+ if (missingThreads.length > 0) {
134
+ this.log(`\n[threads] pulling ${missingThreads.length} open thread(s)…`)
135
+ for (const thread of missingThreads) {
136
+ if (!dryRun) {
137
+ await local.syncThread(thread)
138
+ }
139
+ this.log(` ${dryRun ? 'would pull' : 'pulled'}: ${thread.description}`)
140
+ }
141
+ }
142
+
143
+ this.log(`\nDone. ${dryRun ? '(dry run) ' : ''}${totalPulled} commit(s), ${missingThreads.length} thread(s) pulled from ${remoteUrl}`)
144
+ }
145
+ }