Skip to content

Commit 9f71dab

Browse files
committed
fix: push local commits when remote branch has never been pushed to
When rev-list origin/branch..HEAD fails (remote branch doesn't exist), fall back to rev-list HEAD --count to detect any local commits instead of only checking the current-round committed flag. This fixes the case where a user's first push committed locally but failed to reach the remote, and subsequent pushes reported 'No changes to push' despite having unpushed commits.
1 parent ed63afb commit 9f71dab

2 files changed

Lines changed: 42 additions & 4 deletions

File tree

src/git-ops.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ describe('pushSkills', () => {
197197
mockGit.commit.mockResolvedValue(undefined);
198198
mockGit.push.mockResolvedValue(undefined);
199199
mockGit.branchLocal.mockResolvedValue({ current: 'main' });
200+
mockGit.raw.mockResolvedValue('1\n');
200201

201202
const result = await pushSkills(tempDir, 'test commit');
202203

@@ -240,8 +241,10 @@ describe('pushSkills', () => {
240241
mockGit.add.mockResolvedValue(undefined);
241242
mockGit.commit.mockResolvedValue(undefined);
242243
mockGit.branchLocal.mockResolvedValue({ current: 'main' });
243-
// rev-list fails because origin/main doesn't exist yet
244-
mockGit.raw.mockRejectedValue(new Error('unknown revision origin/main'));
244+
// rev-list origin/main..HEAD fails, then rev-list HEAD --count returns commit count
245+
mockGit.raw
246+
.mockRejectedValueOnce(new Error('unknown revision origin/main'))
247+
.mockResolvedValueOnce('1\n');
245248
mockGit.push.mockResolvedValue(undefined);
246249

247250
const result = await pushSkills(tempDir, 'initial');
@@ -251,12 +254,34 @@ describe('pushSkills', () => {
251254
expect(result).toEqual({ committed: true, pushed: true });
252255
});
253256

257+
it('pushes existing commits when tree is clean but remote branch never existed', async () => {
258+
// User scenario: first push committed but failed to push (no gh cli/remote repo),
259+
// then user installs gh cli + creates remote, runs push again — tree is clean but
260+
// local has commits that were never pushed
261+
mockGit.add.mockResolvedValue(undefined);
262+
mockGit.status.mockResolvedValue({ isClean: () => true });
263+
mockGit.branchLocal.mockResolvedValue({ current: 'main' });
264+
// rev-list origin/main..HEAD fails (no remote branch), rev-list HEAD --count shows commits exist
265+
mockGit.raw
266+
.mockRejectedValueOnce(new Error('unknown revision origin/main'))
267+
.mockResolvedValueOnce('3\n');
268+
mockGit.push.mockResolvedValue(undefined);
269+
270+
const result = await pushSkills(tempDir);
271+
272+
expect(mockGit.commit).not.toHaveBeenCalled();
273+
expect(mockGit.push).toHaveBeenCalledWith('origin', 'main', { '--set-upstream': null });
274+
expect(result.committed).toBe(false);
275+
expect(result.pushed).toBe(true);
276+
});
277+
254278
it('uses token with temporary remote URL and restores clean URL', async () => {
255279
mockGit.status.mockResolvedValue({ isClean: () => false });
256280
mockGit.add.mockResolvedValue(undefined);
257281
mockGit.commit.mockResolvedValue(undefined);
258282
mockGit.push.mockResolvedValue(undefined);
259283
mockGit.branchLocal.mockResolvedValue({ current: 'main' });
284+
mockGit.raw.mockResolvedValue('1\n');
260285
mockGit.getRemotes.mockResolvedValue([
261286
{ name: 'origin', refs: { fetch: 'https://github.com/user/repo.git', push: 'https://github.com/user/repo.git' } },
262287
]);
@@ -274,6 +299,7 @@ describe('pushSkills', () => {
274299
mockGit.add.mockResolvedValue(undefined);
275300
mockGit.commit.mockResolvedValue(undefined);
276301
mockGit.branchLocal.mockResolvedValue({ current: 'main' });
302+
mockGit.raw.mockResolvedValue('1\n');
277303
mockGit.getRemotes.mockResolvedValue([
278304
{ name: 'origin', refs: { fetch: '', push: '' } },
279305
]);
@@ -289,6 +315,7 @@ describe('pushSkills', () => {
289315
mockGit.commit.mockResolvedValue(undefined);
290316
mockGit.push.mockResolvedValue(undefined);
291317
mockGit.branchLocal.mockResolvedValue({ current: 'master' });
318+
mockGit.raw.mockResolvedValue('1\n');
292319

293320
await pushSkills(tempDir, 'test', null);
294321

@@ -302,6 +329,7 @@ describe('pushSkills', () => {
302329
mockGit.commit.mockResolvedValue(undefined);
303330
mockGit.push.mockResolvedValue(undefined);
304331
mockGit.branchLocal.mockResolvedValue({ current: 'main' });
332+
mockGit.raw.mockResolvedValue('1\n');
305333

306334
await pushSkills(tempDir);
307335

@@ -314,6 +342,7 @@ describe('pushSkills', () => {
314342
mockGit.add.mockResolvedValue(undefined);
315343
mockGit.commit.mockResolvedValue(undefined);
316344
mockGit.branchLocal.mockResolvedValue({ current: 'main' });
345+
mockGit.raw.mockResolvedValue('1\n');
317346
mockGit.push.mockRejectedValue(new Error('error: failed to push some refs... non-fast-forward'));
318347

319348
await expect(pushSkills(tempDir, 'test')).rejects.toThrow('Push rejected');
@@ -325,6 +354,7 @@ describe('pushSkills', () => {
325354
mockGit.add.mockResolvedValue(undefined);
326355
mockGit.commit.mockResolvedValue(undefined);
327356
mockGit.branchLocal.mockResolvedValue({ current: 'main' });
357+
mockGit.raw.mockResolvedValue('1\n');
328358
mockGit.getRemotes.mockResolvedValue([
329359
{ name: 'origin', refs: { fetch: 'https://github.com/user/repo.git', push: 'https://github.com/user/repo.git' } },
330360
]);
@@ -339,6 +369,7 @@ describe('pushSkills', () => {
339369
mockGit.add.mockResolvedValue(undefined);
340370
mockGit.commit.mockResolvedValue(undefined);
341371
mockGit.branchLocal.mockResolvedValue({ current: 'main' });
372+
mockGit.raw.mockResolvedValue('1\n');
342373
mockGit.getRemotes.mockResolvedValue([
343374
{ name: 'origin', refs: { fetch: 'https://github.com/user/repo.git', push: 'https://github.com/user/repo.git' } },
344375
]);
@@ -357,6 +388,7 @@ describe('pushSkills', () => {
357388
mockGit.add.mockResolvedValue(undefined);
358389
mockGit.commit.mockResolvedValue(undefined);
359390
mockGit.branchLocal.mockResolvedValue({ current: 'main' });
391+
mockGit.raw.mockResolvedValue('1\n');
360392
mockGit.push.mockRejectedValue(new Error('network timeout'));
361393

362394
await expect(pushSkills(tempDir, 'test')).rejects.toThrow('network timeout');

src/git-ops.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,14 @@ export async function pushSkills(
147147
const count = await git.raw(['rev-list', `origin/${branch}..HEAD`, '--count']);
148148
aheadCount = parseInt(count.trim(), 10) || 0;
149149
} catch {
150-
// Remote branch may not exist yet (first push) — treat as ahead if we committed
151-
aheadCount = committed ? 1 : 0;
150+
// Remote branch may not exist yet (first push) — check if local has ANY commits
151+
try {
152+
const localCount = await git.raw(['rev-list', 'HEAD', '--count']);
153+
aheadCount = parseInt(localCount.trim(), 10) || 0;
154+
} catch {
155+
// No commits at all (empty repo)
156+
aheadCount = 0;
157+
}
152158
}
153159

154160
if (aheadCount === 0) {

0 commit comments

Comments
 (0)