* fix: make upstream sync actions target the selected remote Ensure fetch and pull actually honor upstream selection so fork maintenance works from the Git sidebar, and surface upstream branch status alongside the primary origin-tracking indicators. * feat: add Windows Electron desktop foundation * fix(electron): stabilize Windows desktop packaging * fix(electron): stabilize Windows desktop chrome Use native Windows titlebar behavior with an Alt-accessible hidden menu, and harden Windows dev command launching so the desktop app follows platform conventions. * fix(electron): stabilize Windows dev startup * fix(electron): clarify desktop artifact names * fix(electron): harden Windows desktop release and launch * fix(electron): address Windows release review * fix(electron): point updater and release links to org repo * Fix Windows settings persistence fallback * Fix Windows Electron dev startup * Add Windows Electron window controls * Fix Windows Electron install and opencode launch * fix: resolve git status for repositories without upstream Fixes repository detection stuck on Checking repository Handles git status when no upstream is configured Adds regression coverage for git status loading * Add Windows app menu button * fix: preserve file editor line endings * ci: add desktop release smoke workflow --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
103 lines
3.5 KiB
JavaScript
103 lines
3.5 KiB
JavaScript
import { execFileSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { getStatus, resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js';
|
|
|
|
const tempDirs = [];
|
|
|
|
const createTempDir = () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
};
|
|
|
|
const runGit = (cwd, args) => execFileSync('git', args, {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
const canRunGit = () => {
|
|
try {
|
|
execFileSync('git', ['--version'], { stdio: 'ignore' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('resolveBaseRefForLog', () => {
|
|
it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
|
|
// Both local 'main' and 'refs/remotes/origin/main' are present.
|
|
// The local ref takes precedence — callers that ask for 'main' get 'main'.
|
|
const checkRef = async (ref) => ref === 'main' || ref === 'refs/remotes/origin/main';
|
|
expect(await resolveBaseRefForLog('main', checkRef)).toBe('main');
|
|
});
|
|
|
|
it('falls back to origin/<from> when local ref cannot be resolved but origin can', async () => {
|
|
// Local 'main' is absent (e.g. user never checked it out), but origin/main exists.
|
|
const checkRef = async (ref) => ref === 'refs/remotes/origin/main';
|
|
expect(await resolveBaseRefForLog('main', checkRef)).toBe('origin/main');
|
|
});
|
|
|
|
it('returns the original ref when neither local nor origin ref can be resolved', async () => {
|
|
// Neither ref exists; return as-is so git surfaces a meaningful error.
|
|
const checkRef = async () => false;
|
|
expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch');
|
|
});
|
|
|
|
it('returns undefined when from is undefined', async () => {
|
|
const checkRef = async () => true;
|
|
expect(await resolveBaseRefForLog(undefined, checkRef)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when from is an empty string', async () => {
|
|
const checkRef = async () => true;
|
|
expect(await resolveBaseRefForLog('', checkRef)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when from is a whitespace-only string', async () => {
|
|
const checkRef = async () => true;
|
|
expect(await resolveBaseRefForLog(' ', checkRef)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('git index path validation', () => {
|
|
it('rejects stage paths outside the repository before invoking git', async () => {
|
|
await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
|
|
});
|
|
|
|
it('rejects unstage paths outside the repository before invoking git', async () => {
|
|
await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
|
|
});
|
|
});
|
|
|
|
describe('getStatus', () => {
|
|
it('handles repositories without upstream tracking', async () => {
|
|
if (!canRunGit()) {
|
|
return;
|
|
}
|
|
|
|
const repo = createTempDir();
|
|
runGit(repo, ['init', '-b', 'main']);
|
|
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
|
runGit(repo, ['add', 'README.md']);
|
|
runGit(repo, ['commit', '-m', 'Initial commit']);
|
|
|
|
await expect(getStatus(repo)).resolves.toMatchObject({
|
|
current: 'main',
|
|
});
|
|
});
|
|
});
|