fix: added gitignore handling
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { simpleGit } from 'simple-git';
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface GitAvailability {
|
||||
@@ -60,6 +60,12 @@ export interface GitInitResult {
|
||||
code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed' | 'commit-failed';
|
||||
}
|
||||
|
||||
export interface GitIgnoreEnsureResult {
|
||||
updated: boolean;
|
||||
created: boolean;
|
||||
addedEntries: string[];
|
||||
}
|
||||
|
||||
let gitEngineInstance: GitEngine | null = null;
|
||||
|
||||
export function getGitEngine(): GitEngine {
|
||||
@@ -70,10 +76,21 @@ export function getGitEngine(): GitEngine {
|
||||
}
|
||||
|
||||
export class GitEngine {
|
||||
private readonly defaultGitignoreEntries = [
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
'Desktop.ini',
|
||||
'$RECYCLE.BIN/',
|
||||
'.Spotlight-V100/',
|
||||
'.Trashes/',
|
||||
'._*',
|
||||
'.fseventsd',
|
||||
];
|
||||
|
||||
private async readLfsTrackedPatterns(projectPath: string): Promise<Set<string>> {
|
||||
try {
|
||||
const attributesPath = path.join(projectPath, '.gitattributes');
|
||||
const content = await readFile(attributesPath, 'utf8');
|
||||
const content = await fsPromises.readFile(attributesPath, 'utf8');
|
||||
const patterns = content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
@@ -110,7 +127,7 @@ export class GitEngine {
|
||||
|
||||
for (const target of targets) {
|
||||
try {
|
||||
await stat(path.join(projectPath, target));
|
||||
await fsPromises.stat(path.join(projectPath, target));
|
||||
existing.push(target);
|
||||
} catch {
|
||||
continue;
|
||||
@@ -187,6 +204,52 @@ export class GitEngine {
|
||||
};
|
||||
}
|
||||
|
||||
async ensureGitignore(projectPath: string): Promise<GitIgnoreEnsureResult> {
|
||||
const gitignorePath = path.join(projectPath, '.gitignore');
|
||||
|
||||
let existingContent = '';
|
||||
let created = false;
|
||||
|
||||
try {
|
||||
existingContent = await fsPromises.readFile(gitignorePath, 'utf8');
|
||||
} catch {
|
||||
created = true;
|
||||
}
|
||||
|
||||
const existingEntries = new Set(
|
||||
existingContent
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('#')),
|
||||
);
|
||||
|
||||
const addedEntries = this.defaultGitignoreEntries.filter((entry) => !existingEntries.has(entry));
|
||||
|
||||
if (addedEntries.length === 0) {
|
||||
return {
|
||||
updated: false,
|
||||
created: false,
|
||||
addedEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
if (existingContent.trim().length > 0) {
|
||||
sections.push(existingContent.trimEnd());
|
||||
}
|
||||
|
||||
sections.push('# System metadata');
|
||||
sections.push(...addedEntries);
|
||||
|
||||
await fsPromises.writeFile(gitignorePath, `${sections.join('\n')}\n`, 'utf8');
|
||||
|
||||
return {
|
||||
updated: true,
|
||||
created,
|
||||
addedEntries,
|
||||
};
|
||||
}
|
||||
|
||||
async initializeRepo(
|
||||
projectPath: string,
|
||||
remoteUrl?: string,
|
||||
|
||||
@@ -55,6 +55,11 @@ export function registerIpcHandlers(): void {
|
||||
});
|
||||
});
|
||||
|
||||
safeHandle('git:ensureGitignore', async (_, projectPath: string) => {
|
||||
const engine = getGitEngine();
|
||||
return engine.ensureGitignore(projectPath);
|
||||
});
|
||||
|
||||
// ============ Project Handlers ============
|
||||
|
||||
safeHandle('projects:create', async (_, data: { name: string; description?: string; slug?: string; dataPath?: string }) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export const electronAPI: ElectronAPI = {
|
||||
checkAvailability: () => ipcRenderer.invoke('git:checkAvailability'),
|
||||
getRepoState: (projectPath: string) => ipcRenderer.invoke('git:getRepoState', projectPath),
|
||||
getStatus: (projectPath: string) => ipcRenderer.invoke('git:status', projectPath),
|
||||
ensureGitignore: (projectPath: string) => ipcRenderer.invoke('git:ensureGitignore', projectPath),
|
||||
init: (projectPath: string, remoteUrl?: string) => {
|
||||
if (remoteUrl) {
|
||||
return ipcRenderer.invoke('git:init', projectPath, remoteUrl);
|
||||
|
||||
@@ -260,6 +260,12 @@ export interface GitInitResult {
|
||||
code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed' | 'commit-failed';
|
||||
}
|
||||
|
||||
export interface GitIgnoreEnsureResult {
|
||||
updated: boolean;
|
||||
created: boolean;
|
||||
addedEntries: string[];
|
||||
}
|
||||
|
||||
// Post-Media Link types
|
||||
export interface MediaLinkData {
|
||||
id: string;
|
||||
@@ -334,6 +340,7 @@ export interface ElectronAPI {
|
||||
checkAvailability: () => Promise<GitAvailability>;
|
||||
getRepoState: (projectPath: string) => Promise<GitRepoState>;
|
||||
getStatus: (projectPath: string) => Promise<GitStatusDto>;
|
||||
ensureGitignore: (projectPath: string) => Promise<GitIgnoreEnsureResult>;
|
||||
init: (projectPath: string, remoteUrl?: string) => Promise<GitInitResult>;
|
||||
onInitProgress: (callback: (progress: GitInitProgress) => void) => () => void;
|
||||
};
|
||||
|
||||
@@ -14,11 +14,18 @@
|
||||
}
|
||||
|
||||
.git-sidebar-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 16px 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.git-sidebar-main {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.git-sidebar-empty p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
@@ -32,20 +39,30 @@
|
||||
}
|
||||
|
||||
.git-sidebar-transcript {
|
||||
margin-top: 10px;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--vscode-editorWidget-border);
|
||||
}
|
||||
|
||||
.git-sidebar-transcript-title {
|
||||
margin: 0 0 6px;
|
||||
.git-sidebar-transcript-toggle {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
}
|
||||
|
||||
.git-sidebar-transcript-toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.git-sidebar-transcript-list {
|
||||
margin: 0;
|
||||
margin: 6px 0 0;
|
||||
padding-left: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
|
||||
@@ -13,6 +13,7 @@ export const GitSidebar: React.FC = () => {
|
||||
const [currentBranch, setCurrentBranch] = useState<string | null>(null);
|
||||
const [initProgress, setInitProgress] = useState<GitInitProgress | null>(null);
|
||||
const [initTranscript, setInitTranscript] = useState<GitInitProgress[]>([]);
|
||||
const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false);
|
||||
const remoteUrlInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const resolveProjectPath = useCallback(async (): Promise<string | null> => {
|
||||
@@ -48,6 +49,8 @@ export const GitSidebar: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electronAPI.git.ensureGitignore(resolvedProjectPath);
|
||||
|
||||
const repoState = await window.electronAPI.git.getRepoState(resolvedProjectPath);
|
||||
setIsRepo(repoState.isRepo);
|
||||
setCurrentBranch(repoState.currentBranch || null);
|
||||
@@ -67,6 +70,9 @@ export const GitSidebar: React.FC = () => {
|
||||
const unsubscribe = window.electronAPI.git.onInitProgress((progress) => {
|
||||
setInitProgress(progress);
|
||||
setInitTranscript((previous) => [...previous, progress].slice(-12));
|
||||
if (progress.phase === 'failed') {
|
||||
setIsTranscriptExpanded(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -117,15 +123,24 @@ export const GitSidebar: React.FC = () => {
|
||||
|
||||
const transcriptSection = initTranscript.length > 0 ? (
|
||||
<div className="git-sidebar-transcript">
|
||||
<p className="git-sidebar-transcript-title">Initialization transcript</p>
|
||||
<ul className="git-sidebar-transcript-list">
|
||||
{initTranscript.map((entry, index) => (
|
||||
<li key={`${entry.phase}-${entry.progress}-${index}`}>
|
||||
{entry.progress}% — {entry.message.toLowerCase().replace(/\.+$/, '')}
|
||||
{entry.detail ? ` (${entry.detail})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="git-sidebar-transcript-toggle"
|
||||
onClick={() => setIsTranscriptExpanded((previous) => !previous)}
|
||||
aria-expanded={isTranscriptExpanded}
|
||||
>
|
||||
Initialization transcript
|
||||
</button>
|
||||
{isTranscriptExpanded && (
|
||||
<ul className="git-sidebar-transcript-list">
|
||||
{initTranscript.map((entry, index) => (
|
||||
<li key={`${entry.phase}-${entry.progress}-${index}`}>
|
||||
{entry.progress}% — {entry.message.toLowerCase().replace(/\.+$/, '')}
|
||||
{entry.detail ? ` (${entry.detail})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -134,8 +149,10 @@ export const GitSidebar: React.FC = () => {
|
||||
<div className="git-sidebar">
|
||||
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
||||
<div className="git-sidebar-empty">
|
||||
<p>Git repository ready</p>
|
||||
{currentBranch && <p>Branch: {currentBranch}</p>}
|
||||
<div className="git-sidebar-main">
|
||||
<p>Git repository ready</p>
|
||||
{currentBranch && <p>Branch: {currentBranch}</p>}
|
||||
</div>
|
||||
{transcriptSection}
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,29 +163,31 @@ export const GitSidebar: React.FC = () => {
|
||||
<div className="git-sidebar">
|
||||
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
||||
<div className="git-sidebar-empty">
|
||||
<p>This project is not a git repository.</p>
|
||||
<input
|
||||
ref={remoteUrlInputRef}
|
||||
className="git-sidebar-input"
|
||||
type="text"
|
||||
placeholder="Optional remote repository URL"
|
||||
disabled={initializing}
|
||||
/>
|
||||
{initializing && (
|
||||
<p className="git-sidebar-progress">
|
||||
{initProgress?.message || 'Initializing repository...'}
|
||||
{typeof initProgress?.progress === 'number' ? ` (${initProgress.progress}%)` : ''}
|
||||
{initProgress?.detail ? ` — ${initProgress.detail}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="git-sidebar-error">{error}</p>}
|
||||
<button
|
||||
className="git-sidebar-button"
|
||||
onClick={handleInitialize}
|
||||
disabled={initializing || !projectPath}
|
||||
>
|
||||
{initializing ? 'Initializing...' : 'Initialize Git'}
|
||||
</button>
|
||||
<div className="git-sidebar-main">
|
||||
<p>This project is not a git repository.</p>
|
||||
<input
|
||||
ref={remoteUrlInputRef}
|
||||
className="git-sidebar-input"
|
||||
type="text"
|
||||
placeholder="Optional remote repository URL"
|
||||
disabled={initializing}
|
||||
/>
|
||||
{initializing && (
|
||||
<p className="git-sidebar-progress">
|
||||
{initProgress?.message || 'Initializing repository...'}
|
||||
{typeof initProgress?.progress === 'number' ? ` (${initProgress.progress}%)` : ''}
|
||||
{initProgress?.detail ? ` — ${initProgress.detail}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="git-sidebar-error">{error}</p>}
|
||||
<button
|
||||
className="git-sidebar-button"
|
||||
onClick={handleInitialize}
|
||||
disabled={initializing || !projectPath}
|
||||
>
|
||||
{initializing ? 'Initializing...' : 'Initialize Git'}
|
||||
</button>
|
||||
</div>
|
||||
{transcriptSection}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,14 +11,16 @@ const mockCommit = vi.fn();
|
||||
const mockGetRemotes = vi.fn();
|
||||
const mockAddRemote = vi.fn();
|
||||
const mockRemote = vi.fn();
|
||||
const { mockReadFile, mockStat } = vi.hoisted(() => ({
|
||||
const { mockReadFile, mockStat, mockWriteFile } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockStat: vi.fn(),
|
||||
mockWriteFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
writeFile: mockWriteFile,
|
||||
default: {},
|
||||
}));
|
||||
|
||||
@@ -47,6 +49,7 @@ describe('GitEngine', () => {
|
||||
vi.clearAllMocks();
|
||||
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
||||
mockStat.mockResolvedValue({});
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockCheckIsRepo.mockResolvedValue(false);
|
||||
gitEngine = new GitEngine();
|
||||
});
|
||||
@@ -136,6 +139,48 @@ describe('GitEngine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureGitignore', () => {
|
||||
it('should create .gitignore with default system metadata entries when missing', async () => {
|
||||
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await gitEngine.ensureGitignore('/tmp/project');
|
||||
|
||||
expect(result.updated).toBe(true);
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.addedEntries.length).toBeGreaterThan(0);
|
||||
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should append missing entries when .gitignore exists but is incomplete', async () => {
|
||||
mockReadFile.mockResolvedValue('node_modules/\n.DS_Store\n');
|
||||
|
||||
const result = await gitEngine.ensureGitignore('/tmp/project');
|
||||
|
||||
expect(result.updated).toBe(true);
|
||||
expect(result.created).toBe(false);
|
||||
expect(result.addedEntries).toContain('Thumbs.db');
|
||||
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not rewrite .gitignore when all default entries already exist', async () => {
|
||||
mockReadFile.mockResolvedValue([
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
'Desktop.ini',
|
||||
'$RECYCLE.BIN/',
|
||||
'.Spotlight-V100/',
|
||||
'.Trashes/',
|
||||
'._*',
|
||||
'.fseventsd',
|
||||
].join('\n'));
|
||||
|
||||
const result = await gitEngine.ensureGitignore('/tmp/project');
|
||||
|
||||
expect(result).toEqual({ updated: false, created: false, addedEntries: [] });
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeRepo', () => {
|
||||
it('should emit detailed progress updates throughout initialization', async () => {
|
||||
mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true });
|
||||
|
||||
@@ -145,6 +145,7 @@ const mockGitEngine = {
|
||||
getRepoState: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
initializeRepo: vi.fn(),
|
||||
ensureGitignore: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTaskManager = {
|
||||
@@ -359,6 +360,25 @@ describe('IPC Handlers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('git:ensureGitignore', () => {
|
||||
it('should pass project path to GitEngine.ensureGitignore', async () => {
|
||||
mockGitEngine.ensureGitignore.mockResolvedValue({
|
||||
updated: true,
|
||||
created: false,
|
||||
addedEntries: ['Thumbs.db'],
|
||||
});
|
||||
|
||||
const result = await invokeHandler('git:ensureGitignore', '/repo');
|
||||
|
||||
expect(mockGitEngine.ensureGitignore).toHaveBeenCalledWith('/repo');
|
||||
expect(result).toEqual({
|
||||
updated: true,
|
||||
created: false,
|
||||
addedEntries: ['Thumbs.db'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Project Handlers ============
|
||||
|
||||
@@ -29,11 +29,20 @@ describe('GitSidebar', () => {
|
||||
checkAvailability: vi.fn().mockResolvedValue({ gitFound: true, version: '2.49.0' }),
|
||||
getRepoState: vi.fn().mockResolvedValue({ isRepo: false, hasRemote: false }),
|
||||
init: vi.fn().mockResolvedValue({ success: true }),
|
||||
ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }),
|
||||
onInitProgress: vi.fn().mockImplementation(() => () => {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('checks gitignore defaults when sidebar loads', async () => {
|
||||
render(<GitSidebar />);
|
||||
|
||||
await screen.findByRole('button', { name: /initialize git/i });
|
||||
|
||||
expect((window as any).electronAPI.git.ensureGitignore).toHaveBeenCalledWith('/repo/path');
|
||||
});
|
||||
|
||||
it('shows Initialize Git button when active project is not a git repository', async () => {
|
||||
render(<GitSidebar />);
|
||||
|
||||
@@ -139,7 +148,7 @@ describe('GitSidebar', () => {
|
||||
subscription({ message: 'Staging project files...', progress: 75 });
|
||||
});
|
||||
|
||||
expect(screen.getByText(/75% — staging project files/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/staging project files\.\.\.\s*\(75%\)/i)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveInit?.({ success: true });
|
||||
@@ -170,7 +179,16 @@ describe('GitSidebar', () => {
|
||||
subscription({ message: 'Initializing repository...', progress: 15 });
|
||||
});
|
||||
|
||||
expect(screen.getByText(/initialization transcript/i)).toBeInTheDocument();
|
||||
const transcriptToggle = screen.getByRole('button', { name: /initialization transcript/i });
|
||||
expect(transcriptToggle).toBeInTheDocument();
|
||||
expect(transcriptToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText(/5% — checking git availability/i)).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(transcriptToggle);
|
||||
});
|
||||
|
||||
expect(transcriptToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText(/5% — checking git availability/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/15% — initializing repository/i)).toBeInTheDocument();
|
||||
|
||||
@@ -178,4 +196,19 @@ describe('GitSidebar', () => {
|
||||
resolveInit?.({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-expands transcript when a failed progress event is received', async () => {
|
||||
render(<GitSidebar />);
|
||||
|
||||
const onInitProgressMock = (window as any).electronAPI.git.onInitProgress as ReturnType<typeof vi.fn>;
|
||||
const subscription = onInitProgressMock.mock.calls[0][0] as (payload: { phase: string; message: string; progress: number }) => void;
|
||||
|
||||
await act(async () => {
|
||||
subscription({ phase: 'failed', message: 'Failed to configure remote repository.', progress: 100 });
|
||||
});
|
||||
|
||||
const transcriptToggle = screen.getByRole('button', { name: /initialization transcript/i });
|
||||
expect(transcriptToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText(/100% — failed to configure remote repository/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user