Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,30 @@ export function ensureGitignoreEntries(cwd: string, entries: string[]): void {

/**
* Configure git to push notes automatically with regular pushes.
* Adds refs/notes/* to existing push refspecs, preserving any user-configured refspecs.
* Adds refs/notes/* only when custom push refspecs already exist.
* This avoids overriding git's default push behavior in repos that
* rely on implicit push semantics (no remote.origin.push configured).
* Idempotent - safe to call multiple times.
*/
export function configureNotesPush(cwd: string): void {
export type NotesPushConfigStatus =
| 'configured'
| 'already-configured'
| 'skipped-no-custom-push'
| 'skipped-no-origin';

export function configureNotesPush(cwd: string): { status: NotesPushConfigStatus } {
let existingRefspecs: string[] = [];

try {
execFileSync('git', ['remote', 'get-url', 'origin'], {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
return { status: 'skipped-no-origin' };
}

try {
// Get existing push refspecs
const existing = execFileSync('git', ['config', '--local', '--get-all', 'remote.origin.push'], {
Expand All @@ -101,25 +119,25 @@ export function configureNotesPush(cwd: string): void {

// Already has notes configured - nothing to do
if (existingRefspecs.some((ref) => ref.includes('refs/notes'))) {
return;
return { status: 'already-configured' };
}
} catch {
// Config doesn't exist yet, proceed to set it
// Config doesn't exist yet
}

// If no existing refspecs, add heads first
// Without explicit push refspecs, adding one would override default
// git push behavior (often current/upstream branch).
if (existingRefspecs.length === 0) {
execFileSync('git', ['config', '--local', 'remote.origin.push', '+refs/heads/*:refs/heads/*'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
return { status: 'skipped-no-custom-push' };
}

// Add notes refspec (preserves existing refspecs)
execFileSync('git', ['config', '--local', '--add', 'remote.origin.push', '+refs/notes/*:refs/notes/*'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});

return { status: 'configured' };
}

/**
Expand Down Expand Up @@ -295,8 +313,17 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger
}

// Configure git to push notes automatically with regular pushes
configureNotesPush(cwd);
console.log('✓ Configured git to push notes with commits');
const notesPush = configureNotesPush(cwd);
if (notesPush.status === 'configured') {
console.log('✓ Configured git to push notes with commits');
} else if (notesPush.status === 'already-configured') {
console.log('✓ Notes push already configured (skipped)');
} else if (notesPush.status === 'skipped-no-custom-push') {
console.log('i Skipped notes push auto-config to preserve default git push behavior');
console.log(' Use `git-mem sync --push` to publish notes, or add a custom remote.origin.push refspec.');
} else {
console.log('i No origin remote found; skipped notes push configuration');
}
}

// ── MCP config (skip if already exists) ────────────────────────
Expand Down
101 changes: 100 additions & 1 deletion tests/unit/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,36 @@

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, readFileSync } from 'fs';
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { rmSync } from 'fs';
import { execFileSync } from 'child_process';
import {
ensureGitignoreEntries,
configureNotesPush,
readEnvApiKey,
ensureEnvPlaceholder,
} from '../../../src/commands/init';

function git(args: string[], cwd: string): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
}

function getPushRefspecs(cwd: string): string[] {
try {
const output = git(['config', '--local', '--get-all', 'remote.origin.push'], cwd);
if (!output) return [];
return output.split('\n').filter(Boolean);
} catch {
return [];
}
}

// ── ensureGitignoreEntries ───────────────────────────────────────────

describe('ensureGitignoreEntries', () => {
Expand Down Expand Up @@ -271,3 +291,82 @@ describe('ensureEnvPlaceholder', () => {
}
});
});

// ── configureNotesPush ────────────────────────────────────────────────

describe('configureNotesPush', () => {
it('should skip when origin remote is not configured', () => {
const dir = mkdtempSync(join(tmpdir(), 'git-mem-notes-push-'));
try {
git(['init'], dir);
const result = configureNotesPush(dir);
assert.equal(result.status, 'skipped-no-origin');
assert.deepEqual(getPushRefspecs(dir), []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it('should skip when no custom remote.origin.push refspec exists', () => {
const base = mkdtempSync(join(tmpdir(), 'git-mem-notes-push-'));
const dir = join(base, 'repo');
const remote = join(base, 'remote.git');
try {
mkdirSync(dir, { recursive: true });
git(['init'], dir);
git(['init', '--bare', remote], base);
git(['remote', 'add', 'origin', remote], dir);

const result = configureNotesPush(dir);
assert.equal(result.status, 'skipped-no-custom-push');
assert.deepEqual(getPushRefspecs(dir), []);
} finally {
rmSync(base, { recursive: true, force: true });
}
});

it('should append notes refspec when custom push refspec exists', () => {
const base = mkdtempSync(join(tmpdir(), 'git-mem-notes-push-'));
const dir = join(base, 'repo');
const remote = join(base, 'remote.git');
try {
mkdirSync(dir, { recursive: true });
git(['init'], dir);
git(['init', '--bare', remote], base);
git(['remote', 'add', 'origin', remote], dir);
git(['config', '--local', 'remote.origin.push', 'refs/heads/main:refs/heads/main'], dir);

const result = configureNotesPush(dir);
assert.equal(result.status, 'configured');

const refspecs = getPushRefspecs(dir);
assert.ok(refspecs.includes('refs/heads/main:refs/heads/main'));
assert.ok(refspecs.includes('+refs/notes/*:refs/notes/*'));
} finally {
rmSync(base, { recursive: true, force: true });
}
});

it('should be idempotent when notes refspec already exists', () => {
const base = mkdtempSync(join(tmpdir(), 'git-mem-notes-push-'));
const dir = join(base, 'repo');
const remote = join(base, 'remote.git');
try {
mkdirSync(dir, { recursive: true });
git(['init'], dir);
git(['init', '--bare', remote], base);
git(['remote', 'add', 'origin', remote], dir);
git(['config', '--local', '--add', 'remote.origin.push', 'refs/heads/main:refs/heads/main'], dir);
git(['config', '--local', '--add', 'remote.origin.push', '+refs/notes/*:refs/notes/*'], dir);

const result = configureNotesPush(dir);
assert.equal(result.status, 'already-configured');

const refspecs = getPushRefspecs(dir);
const notesRefs = refspecs.filter((ref) => ref === '+refs/notes/*:refs/notes/*');
assert.equal(notesRefs.length, 1);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});