gent-cli 2.1.0 → 5.0.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.
@@ -1,123 +1,213 @@
1
1
  /**
2
- * Pull Command - Pull commits from cloud to local repository
3
- * Downloads commits, trees, and blobs from the cloud
2
+ * ============================================================================
3
+ * Pull Command - Fetch and merge remote commits into local branch
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Download new commits from remote and merge into current branch.
8
+ * Like `git pull` (fetch + merge in one step).
9
+ *
10
+ * USAGE:
11
+ * gent pull → Pull from origin/current-branch
12
+ * gent pull <remote> <branch> → Pull specific remote/branch
13
+ *
14
+ * ALGORITHM:
15
+ * 1. GET /api/repos/:id/pull/?branch=<branch>&since=<lastKnownHash>
16
+ * 2. Receive commits + blob objects
17
+ * 3. Store blobs in local object store
18
+ * 4. Append commits to local history
19
+ * 5. If diverged: run 3-way merge (same as gent merge)
20
+ * 6. If fast-forward: just advance pointer
21
+ *
22
+ * BACKEND EXPECTATIONS:
23
+ * GET /api/repos/:id/pull/?branch=main&since=abc1234
24
+ * Returns:
25
+ * {
26
+ * branch: "main",
27
+ * commits: [...],
28
+ * objects: [ { hash, type, data: "<base64>" } ],
29
+ * head: "<remoteHeadHash>"
30
+ * }
31
+ *
32
+ * ============================================================================
4
33
  */
5
34
 
6
- const chalk = require('chalk');
35
+ const fs = require('fs').promises;
7
36
  const path = require('path');
37
+ const chalk = require('chalk');
8
38
  const ora = require('ora');
9
- const { pathExists, readJSON, writeJSON } = require('../utils/fileSystem');
10
- const { GENT_DIR, COMMITS_FILE } = require('../utils/constants');
11
- const { getRemote, syncCommitsFromCloud } = require('../utils/cloud-sync');
39
+ const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
40
+ const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
41
+ const apiClient = require('../utils/api-client');
12
42
  const authStorage = require('../utils/auth-storage');
13
- const repoService = require('../services/repo-service');
43
+ const { storeBlob, objectExists } = require('../utils/hash-engine');
44
+ const { findMergeBase, mergeTreeEntries } = require('../utils/merge-engine');
45
+ const { generateCommitHash } = require('../utils/helpers');
14
46
 
15
47
  /**
16
- * Pull commits from cloud to local
17
- * @param {string} remoteName - Remote name (default: origin)
18
- * @param {string} branchName - Branch name (optional)
19
- * @param {Object} options - Command options
48
+ * Pull remote commits
49
+ * @param {String} remoteName
50
+ * @param {String} branchName
51
+ * @param {Object} options
20
52
  */
21
53
  async function pull(remoteName, branchName, options) {
54
+ const spinner = ora('Pulling from remote...').start();
55
+
22
56
  try {
23
- const cwd = process.cwd();
24
- const gentPath = path.join(cwd, GENT_DIR);
25
-
26
- // Check if in a gent repository
27
- if (!(await pathExists(gentPath))) {
28
- console.error(chalk.red('Error: Not a gent repository'));
29
- console.log(chalk.yellow('Run'), chalk.cyan('gent init'), chalk.yellow('to initialize a repository'));
30
- process.exit(1);
57
+ const isAuth = await authStorage.isAuthenticated();
58
+ if (!isAuth) {
59
+ spinner.fail(chalk.red('Not authenticated'));
60
+ console.log(chalk.yellow('Run "gent login" first'));
61
+ return;
31
62
  }
32
63
 
33
- // Check authentication
34
- const user = await authStorage.getUser();
35
- if (!user) {
36
- console.error(chalk.red('Error: You must be logged in to pull'));
37
- console.log(chalk.yellow('Run'), chalk.cyan('gent login'), chalk.yellow('to authenticate'));
38
- process.exit(1);
64
+ const gentPath = await getGentPath();
65
+ const config = await readJSON(path.join(gentPath, CONFIG_FILE));
66
+ config.remotes = config.remotes || {};
67
+
68
+ const remote = remoteName || 'origin';
69
+ const remoteConfig = config.remotes[remote];
70
+ if (!remoteConfig) {
71
+ spinner.fail(chalk.red(`Remote '${remote}' not found`));
72
+ return;
39
73
  }
40
74
 
41
- // Default to 'origin' if no remote specified
42
- remoteName = remoteName || 'origin';
75
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
76
+ const branch = branchName || repository.currentBranch;
77
+ const localHead = repository.branches[branch] || null;
43
78
 
44
- // Get remote configuration
45
- const remote = await getRemote(remoteName, cwd);
46
- if (!remote) {
47
- console.error(chalk.red(`Error: Remote '${remoteName}' not found`));
48
- console.log(chalk.yellow('Add a remote with:'), chalk.cyan('gent remote add origin <owner_id>/<repo_name>'));
49
- console.log(chalk.yellow('Or list remotes with:'), chalk.cyan('gent remote'));
50
- process.exit(1);
51
- }
79
+ // Fetch from remote
80
+ config.remoteRefs = config.remoteRefs || {};
81
+ const since = config.remoteRefs[`${remote}/${branch}`] || localHead || '';
52
82
 
53
- // Get current branch if not specified
54
- const commitsData = await readJSON(path.join(gentPath, COMMITS_FILE));
55
- branchName = branchName || commitsData.currentBranch;
83
+ spinner.text = `Fetching from ${remote}/${branch}...`;
56
84
 
57
- if (!branchName) {
58
- console.error(chalk.red('Error: No branch specified and no current branch found'));
59
- process.exit(1);
60
- }
85
+ const response = await apiClient.get(
86
+ `${remoteConfig.url}/pull/`,
87
+ { params: { branch, since } }
88
+ );
61
89
 
62
- console.log(chalk.cyan(`Pulling from ${remoteName}/${branchName}...\n`));
90
+ const remoteCommits = response.commits || [];
91
+ const remoteObjects = response.objects || [];
92
+ const remoteHead = response.head;
63
93
 
64
- // Verify repository exists
65
- const spinner = ora('Verifying remote repository...').start();
66
- try {
67
- await repoService.getRepository(remote.owner_id, remote.repo_name);
68
- spinner.succeed('Remote repository verified');
69
- } catch (error) {
70
- spinner.fail('Remote repository not found or access denied');
71
- throw error;
94
+ if (remoteCommits.length === 0) {
95
+ spinner.succeed(chalk.green('Already up-to-date'));
96
+ return;
72
97
  }
73
98
 
74
- // Pull commits
75
- const cloudCommits = await syncCommitsFromCloud(remote.owner_id, remote.repo_name, cwd);
76
-
77
- // Update local commits file with cloud commits
78
- const localCommits = commitsData.commits || [];
79
- const mergedCommits = [...localCommits];
80
-
81
- // Add cloud commits that don't exist locally
82
- for (const cloudCommit of cloudCommits) {
83
- const exists = localCommits.find(c => c.sha === cloudCommit.sha);
84
- if (!exists) {
85
- // Convert cloud commit format to local format
86
- mergedCommits.push({
87
- sha: cloudCommit.sha,
88
- message: cloudCommit.message,
89
- author: {
90
- name: cloudCommit.author_name,
91
- email: cloudCommit.author_email
92
- },
93
- timestamp: cloudCommit.committed_at,
94
- parent: cloudCommit.parent_shas || [],
95
- files: cloudCommit.files || []
96
- });
99
+ // Store received blob objects
100
+ spinner.text = `Storing ${remoteObjects.length} object(s)...`;
101
+ for (const obj of remoteObjects) {
102
+ if (obj.type === 'blob' && obj.data) {
103
+ const buf = Buffer.from(obj.data, 'base64');
104
+ await storeBlob(gentPath, buf);
97
105
  }
98
106
  }
99
107
 
100
- // Update branch pointer if we have cloud commits
101
- if (cloudCommits.length > 0) {
102
- const latestCloudCommit = cloudCommits[cloudCommits.length - 1];
103
- commitsData.branches[branchName] = latestCloudCommit.sha;
108
+ // Check if fast-forward is possible
109
+ const allCommits = [...(repository.commits || []), ...remoteCommits];
110
+ const commitSet = new Set((repository.commits || []).map(c => c.hash));
111
+
112
+ // Add new commits (dedup)
113
+ let newCount = 0;
114
+ for (const commit of remoteCommits) {
115
+ if (!commitSet.has(commit.hash)) {
116
+ repository.commits.push(commit);
117
+ commitSet.add(commit.hash);
118
+ newCount++;
119
+ }
104
120
  }
105
121
 
106
- commitsData.commits = mergedCommits;
122
+ if (!localHead || isAncestor(repository.commits, localHead, remoteHead)) {
123
+ // Fast-forward
124
+ repository.branches[branch] = remoteHead;
125
+ await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
107
126
 
108
- await writeJSON(path.join(gentPath, COMMITS_FILE), commitsData);
127
+ config.remoteRefs[`${remote}/${branch}`] = remoteHead;
128
+ await writeJSON(path.join(gentPath, CONFIG_FILE), config);
109
129
 
110
- if (cloudCommits.length > 0) {
111
- console.log(chalk.green(`\n✓ Successfully pulled ${cloudCommits.length} commit(s) from ${remoteName}/${branchName}`));
130
+ spinner.succeed(chalk.green(`Fast-forward: ${newCount} new commit(s)`));
131
+ console.log(chalk.gray(` ${remote}/${branch} ${remoteHead.substring(0, 7)}`));
112
132
  } else {
113
- console.log(chalk.yellow('Already up to date'));
133
+ // Diverged need 3-way merge
134
+ spinner.text = 'Branches diverged, merging...';
135
+
136
+ const baseHash = findMergeBase(repository.commits, localHead, remoteHead);
137
+ const getTree = (hash) => {
138
+ const c = repository.commits.find(x => x.hash === hash);
139
+ if (!c) return [];
140
+ return c.tree || (c.files || []).map(f => ({
141
+ mode: '100644', name: f.path || f.name, hash: f.hash, type: 'blob'
142
+ }));
143
+ };
144
+
145
+ const baseTree = baseHash ? getTree(baseHash) : [];
146
+ const oursTree = getTree(localHead);
147
+ const theirsTree = getTree(remoteHead);
148
+
149
+ const mergeResult = await mergeTreeEntries(gentPath, baseTree, oursTree, theirsTree);
150
+
151
+ // Build merge commit
152
+ const { storeTree } = require('../utils/hash-engine');
153
+ const mergedTreeHash = await storeTree(gentPath, mergeResult.mergedEntries);
154
+
155
+ const mergeCommit = {
156
+ hash: generateCommitHash(),
157
+ message: `Merge remote-tracking branch '${remote}/${branch}'`,
158
+ author: (repository.commits.find(c => c.hash === localHead) || {}).author || { name: 'Unknown', email: '' },
159
+ timestamp: new Date().toISOString(),
160
+ parent: localHead,
161
+ mergeParent: remoteHead,
162
+ treeHash: mergedTreeHash,
163
+ tree: mergeResult.mergedEntries,
164
+ files: mergeResult.mergedEntries.map(e => ({ path: e.name, hash: e.hash })),
165
+ stats: { filesChanged: mergeResult.mergedEntries.length, insertions: 0, deletions: 0 }
166
+ };
167
+
168
+ repository.commits.push(mergeCommit);
169
+ repository.branches[branch] = mergeCommit.hash;
170
+ await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
171
+
172
+ config.remoteRefs[`${remote}/${branch}`] = remoteHead;
173
+ await writeJSON(path.join(gentPath, CONFIG_FILE), config);
174
+
175
+ if (mergeResult.hasConflicts) {
176
+ spinner.warn(chalk.yellow(`Pulled with ${mergeResult.conflicts.length} conflict(s)`));
177
+ for (const c of mergeResult.conflicts) {
178
+ console.log(chalk.red(` CONFLICT: ${c.file} (${c.type})`));
179
+ }
180
+ console.log(chalk.yellow('\nResolve conflicts, then "gent add" + "gent commit"'));
181
+ } else {
182
+ spinner.succeed(chalk.green(`Merged ${newCount} remote commit(s)`));
183
+ console.log(chalk.gray(` Merge commit: ${mergeCommit.hash.substring(0, 7)}`));
184
+ }
114
185
  }
115
-
116
186
  } catch (error) {
117
- console.error(chalk.red('Failed to pull'));
118
- console.error(chalk.red('Error:'), error.message);
187
+ spinner.fail(chalk.red('Pull failed'));
188
+ if (error.response?.status === 401) {
189
+ console.error(chalk.red('Authentication failed — run "gent login"'));
190
+ } else if (error.response?.data?.message) {
191
+ console.error(chalk.red(error.response.data.message));
192
+ } else {
193
+ console.error(chalk.red('Error:'), error.message);
194
+ }
119
195
  process.exit(1);
120
196
  }
121
197
  }
122
198
 
199
+ /**
200
+ * Check if hashA is ancestor of hashB
201
+ */
202
+ function isAncestor(commits, hashA, hashB) {
203
+ const commitMap = new Map(commits.map(c => [c.hash, c]));
204
+ let cur = hashB;
205
+ while (cur) {
206
+ if (cur === hashA) return true;
207
+ const c = commitMap.get(cur);
208
+ cur = c ? c.parent : null;
209
+ }
210
+ return false;
211
+ }
212
+
123
213
  module.exports = pull;
@@ -1,113 +1,206 @@
1
1
  /**
2
- * Push Command - Push local commits to cloud repository
3
- * Uploads commits, trees, and blobs to the cloud
2
+ * ============================================================================
3
+ * Push Command - Upload local commits and objects to remote
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Send local commits, blobs, and tree objects to the backend API server.
8
+ * Like `git push`.
9
+ *
10
+ * USAGE:
11
+ * gent push → Push current branch to origin
12
+ * gent push <remote> <branch> → Push specific branch to remote
13
+ * gent push --force → Force push (overwrite remote)
14
+ *
15
+ * ALGORITHM:
16
+ * 1. Read local commits since last known remote HEAD
17
+ * 2. Collect all blob objects referenced by those commits
18
+ * 3. POST packfile (commits + blobs + trees) to remote /push/ endpoint
19
+ * 4. Remote updates branch pointer
20
+ *
21
+ * DATA FORMAT SENT TO BACKEND:
22
+ * POST /api/repos/:id/push/
23
+ * {
24
+ * branch: "main",
25
+ * force: false,
26
+ * commits: [ { hash, message, author, timestamp, parent, treeHash, tree, files, stats } ],
27
+ * objects: [ { hash, type: "blob", data: "<base64>" } ],
28
+ * tags: { "v1.0": { hash, message, ... } }
29
+ * }
30
+ *
31
+ * BACKEND EXPECTATIONS:
32
+ * - Validate auth (JWT Bearer token)
33
+ * - Verify fast-forward (reject non-ff unless force=true)
34
+ * - Store blob objects in backend object store
35
+ * - Append commits to branch history
36
+ * - Update branch refs
37
+ * - Return { success, ref, hash }
38
+ *
39
+ * ============================================================================
4
40
  */
5
41
 
6
- const chalk = require('chalk');
42
+ const fs = require('fs').promises;
7
43
  const path = require('path');
44
+ const chalk = require('chalk');
8
45
  const ora = require('ora');
9
- const { pathExists, readJSON } = require('../utils/fileSystem');
10
- const { GENT_DIR, COMMITS_FILE } = require('../utils/constants');
11
- const { getRemote, syncCommitsToCloud } = require('../utils/cloud-sync');
46
+ const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
47
+ const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
48
+ const apiClient = require('../utils/api-client');
12
49
  const authStorage = require('../utils/auth-storage');
13
- const repoService = require('../services/repo-service');
50
+ const { readBlob, objectExists } = require('../utils/hash-engine');
14
51
 
15
52
  /**
16
- * Push local commits to cloud
17
- * @param {string} remoteName - Remote name (default: origin)
18
- * @param {string} branchName - Branch name (optional)
19
- * @param {Object} options - Command options
53
+ * Push commits to remote
54
+ * @param {String} remoteName
55
+ * @param {String} branchName
56
+ * @param {Object} options
20
57
  */
21
58
  async function push(remoteName, branchName, options) {
22
- try {
23
- const cwd = process.cwd();
24
- const gentPath = path.join(cwd, GENT_DIR);
25
-
26
- // Check if in a gent repository
27
- if (!(await pathExists(gentPath))) {
28
- console.error(chalk.red('Error: Not a gent repository'));
29
- console.log(chalk.yellow('Run'), chalk.cyan('gent init'), chalk.yellow('to initialize a repository'));
30
- process.exit(1);
31
- }
32
-
33
- // Check authentication
34
- const user = await authStorage.getUser();
35
- if (!user) {
36
- console.error(chalk.red('Error: You must be logged in to push'));
37
- console.log(chalk.yellow('Run'), chalk.cyan('gent login'), chalk.yellow('to authenticate'));
38
- process.exit(1);
39
- }
59
+ const spinner = ora('Preparing push...').start();
40
60
 
41
- // Default to 'origin' if no remote specified
42
- remoteName = remoteName || 'origin';
43
-
44
- // Get remote configuration
45
- const remote = await getRemote(remoteName, cwd);
46
- if (!remote) {
47
- console.error(chalk.red(`Error: Remote '${remoteName}' not found`));
48
- console.log(chalk.yellow('Add a remote with:'), chalk.cyan('gent remote add origin <owner_id>/<repo_name>'));
49
- console.log(chalk.yellow('Or list remotes with:'), chalk.cyan('gent remote'));
50
- process.exit(1);
61
+ try {
62
+ // Auth check
63
+ const isAuth = await authStorage.isAuthenticated();
64
+ if (!isAuth) {
65
+ spinner.fail(chalk.red('Not authenticated'));
66
+ console.log(chalk.yellow('Run "gent login" first'));
67
+ return;
51
68
  }
52
69
 
53
- // Get current branch if not specified
54
- const commitsData = await readJSON(path.join(gentPath, COMMITS_FILE));
55
- branchName = branchName || commitsData.currentBranch;
56
-
57
- if (!branchName) {
58
- console.error(chalk.red('Error: No branch specified and no current branch found'));
59
- process.exit(1);
70
+ const gentPath = await getGentPath();
71
+ const configPath = path.join(gentPath, CONFIG_FILE);
72
+ const config = await readJSON(configPath);
73
+ config.remotes = config.remotes || {};
74
+
75
+ // Resolve remote
76
+ const remote = remoteName || 'origin';
77
+ const remoteConfig = config.remotes[remote];
78
+ if (!remoteConfig) {
79
+ spinner.fail(chalk.red(`Remote '${remote}' not found`));
80
+ console.log(chalk.yellow('Use "gent remote add origin <url>" to configure'));
81
+ return;
60
82
  }
61
83
 
62
- // Get commits for the branch
63
- const commits = commitsData.commits || [];
64
- const branchCommitSha = commitsData.branches[branchName];
84
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
85
+ const branch = branchName || repository.currentBranch;
86
+ const localHead = repository.branches[branch];
65
87
 
66
- if (!branchCommitSha) {
67
- console.error(chalk.red(`Error: Branch '${branchName}' not found`));
68
- process.exit(1);
88
+ if (!localHead) {
89
+ spinner.fail(chalk.red(`Branch '${branch}' has no commits`));
90
+ return;
69
91
  }
70
92
 
71
- // Get commits to push (find all commits in the branch)
72
- const commitsToPush = [];
73
- let currentSha = branchCommitSha;
74
-
75
- while (currentSha) {
76
- const commit = commits.find(c => (c.sha || c.hash) === currentSha);
77
- if (!commit) break;
78
-
79
- commitsToPush.unshift(commit); // Add to beginning to maintain order
80
- const parents = Array.isArray(commit.parent) ? commit.parent : (commit.parent ? [commit.parent] : []);
81
- currentSha = parents.length > 0 ? parents[0] : null;
82
- }
93
+ // Determine which commits to push (since last pushed ref)
94
+ config.remoteRefs = config.remoteRefs || {};
95
+ const lastPushed = config.remoteRefs[`${remote}/${branch}`] || null;
96
+ const commits = repository.commits || [];
97
+ const commitsToPush = getCommitsSince(commits, localHead, lastPushed);
83
98
 
84
99
  if (commitsToPush.length === 0) {
85
- console.log(chalk.yellow('Nothing to push'));
100
+ spinner.succeed(chalk.green('Everything up-to-date'));
86
101
  return;
87
102
  }
88
103
 
89
- console.log(chalk.cyan(`Pushing ${commitsToPush.length} commit(s) to ${remoteName}/${branchName}...`));
104
+ spinner.text = `Pushing ${commitsToPush.length} commit(s) to ${remote}/${branch}...`;
90
105
 
91
- // Verify repository exists
92
- const spinner = ora('Verifying remote repository...').start();
93
- try {
94
- await repoService.getRepository(remote.owner_id, remote.repo_name);
95
- spinner.succeed('Remote repository verified');
96
- } catch (error) {
97
- spinner.fail('Remote repository not found or access denied');
98
- throw error;
106
+ // Collect all blob hashes from commits to push
107
+ const blobHashes = new Set();
108
+ for (const commit of commitsToPush) {
109
+ const tree = commit.tree || commit.files || [];
110
+ for (const entry of tree) {
111
+ const h = entry.hash;
112
+ if (h) blobHashes.add(h);
113
+ }
99
114
  }
100
115
 
101
- // Push commits
102
- await syncCommitsToCloud(commitsToPush, remote.owner_id, remote.repo_name, branchName, cwd);
116
+ // Read blob data for transfer
117
+ const objects = [];
118
+ for (const hash of blobHashes) {
119
+ try {
120
+ if (await objectExists(gentPath, hash)) {
121
+ const data = await readBlob(gentPath, hash);
122
+ objects.push({
123
+ hash,
124
+ type: 'blob',
125
+ data: data.toString('base64')
126
+ });
127
+ }
128
+ } catch {
129
+ // Skip missing blobs
130
+ }
131
+ }
103
132
 
104
- console.log(chalk.green(`\n✓ Successfully pushed to ${remoteName}/${branchName}`));
133
+ // Build push payload
134
+ const payload = {
135
+ branch,
136
+ force: !!options.force,
137
+ commits: commitsToPush.map(c => ({
138
+ hash: c.hash,
139
+ message: c.message,
140
+ author: c.author,
141
+ timestamp: c.timestamp,
142
+ parent: c.parent,
143
+ mergeParent: c.mergeParent || null,
144
+ treeHash: c.treeHash || null,
145
+ tree: c.tree || null,
146
+ files: c.files || [],
147
+ stats: c.stats || {}
148
+ })),
149
+ objects,
150
+ tags: repository.tags || {}
151
+ };
152
+
153
+ // Send to backend
154
+ const response = await apiClient.post(
155
+ `${remoteConfig.url}/push/`,
156
+ payload
157
+ );
158
+
159
+ // Update remote ref
160
+ config.remoteRefs[`${remote}/${branch}`] = localHead;
161
+ await writeJSON(configPath, config);
162
+
163
+ spinner.succeed(chalk.green(`Pushed ${commitsToPush.length} commit(s) to ${remote}/${branch}`));
164
+ console.log(chalk.gray(` ${localHead.substring(0, 7)} → ${remote}/${branch}`));
165
+ console.log(chalk.gray(` ${objects.length} object(s) transferred`));
105
166
 
106
167
  } catch (error) {
107
- console.error(chalk.red('Failed to push'));
108
- console.error(chalk.red('Error:'), error.message);
168
+ spinner.fail(chalk.red('Push failed'));
169
+
170
+ if (error.response?.status === 409) {
171
+ console.error(chalk.red('Remote has changes you don\'t have locally'));
172
+ console.log(chalk.yellow('Run "gent pull" first, or use "gent push --force"'));
173
+ } else if (error.response?.status === 401) {
174
+ console.error(chalk.red('Authentication failed — run "gent login"'));
175
+ } else if (error.response?.data?.message) {
176
+ console.error(chalk.red(error.response.data.message));
177
+ } else {
178
+ console.error(chalk.red('Error:'), error.message);
179
+ }
109
180
  process.exit(1);
110
181
  }
111
182
  }
112
183
 
184
+ /**
185
+ * Get commits from tip back to (but excluding) stopHash.
186
+ * @param {Array} allCommits
187
+ * @param {String} tipHash
188
+ * @param {String|null} stopHash
189
+ * @returns {Array}
190
+ */
191
+ function getCommitsSince(allCommits, tipHash, stopHash) {
192
+ const commitMap = new Map(allCommits.map(c => [c.hash, c]));
193
+ const result = [];
194
+ let current = tipHash;
195
+
196
+ while (current && current !== stopHash) {
197
+ const commit = commitMap.get(current);
198
+ if (!commit) break;
199
+ result.push(commit);
200
+ current = commit.parent;
201
+ }
202
+
203
+ return result.reverse(); // oldest first
204
+ }
205
+
113
206
  module.exports = push;