From 0f04b98d60a0e6b4e0763ba46afe1a3710e746ad Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 16 Feb 2026 17:41:45 +0100 Subject: [PATCH] feat: git implementation finished --- IMPLEMENT_GIT.md | 345 ------------------ tests/engine/GitEngine.test.ts | 20 + tests/renderer/components/GitSidebar.test.tsx | 49 +++ 3 files changed, 69 insertions(+), 345 deletions(-) delete mode 100644 IMPLEMENT_GIT.md diff --git a/IMPLEMENT_GIT.md b/IMPLEMENT_GIT.md deleted file mode 100644 index c1838e6..0000000 --- a/IMPLEMENT_GIT.md +++ /dev/null @@ -1,345 +0,0 @@ -# IMPLEMENT_GIT - -## Goal -Implement a VS Code-like Git sidebar workflow for the current project in bDS with: -- Left sidebar rail sync icon (bottom section, directly above Settings) -- Sidebar split into upper **Open Changes** and lower **Version History** -- File click to open diff view against repository in editor tabs with transient/persistent behavior -- `Initialize Git` action when no repo exists -- Periodic status polling and remote fetch polling -- Fetch / Pull / Push actions in repo header -- Commit message input + `Commit` button (add-all + commit) - -This plan is scoped to the existing Electron architecture: -- Main process business logic in `src/main/engine` -- IPC handlers in `src/main/ipc` -- Typed bridge in `src/main/preload.ts` and `src/main/shared/electronApi` -- Renderer state/UI in `src/renderer/store` and `src/renderer/components` - ---- - -## External Requirements - -## 1) Runtime Dependencies -- **Primary library**: `simple-git` -- **System requirement**: `git` CLI installed and available in PATH -- **System requirement**: `git-lfs` CLI installed and available in PATH -- **Optional later**: fallback to bundled Git binary for users without system Git - -## 2) OS / Packaging -- Support macOS, Linux, Windows path handling and quoting. -- If bundling Git later, include signing/notarization and license notices in release pipeline. - -## 3) Performance Requirements -- Must handle large change sets without list jump/reflow issues. -- Polling and fetch operations must be background/non-blocking and cancellable. - -## 4) Security Requirements -- Run all Git commands in main process only. -- Validate project root path before command execution. -- Never allow arbitrary command injection via renderer inputs. - ---- - -## UX and Behavioral Requirements (from spec) - -## Sidebar Rail Button -- Add a new Git sync icon button in the left sidebar rail bottom section, directly above the Settings icon. -- Clicking icon opens/closes Git sidebar view similarly to existing views. - -## Sidebar Layout (Git view) -- Upper half: **Open Changes** list. -- Lower half: **Version History** list. -- Repo actions in header: **Fetch**, **Pull**, **Push** icons. -- Open Changes area includes: - - Commit message input field - - Commit button (does add-all + commit) - -## No Repository State -- If current project is not a git repo: - - show `Initialize Git` button. - - action runs `git init`, then enables Git LFS for this repository only (`git lfs install --local`, no global hook installation) and tracks image file types (e.g. `*.png`, `*.jpg`, `*.jpeg`, `*.gif`, `*.webp`, `*.svg`, `*.avif`, `*.heic`) so binary image assets are excluded from normal Git object/version storage. - - if git executable not found, show explicit install guidance message. - - if Git LFS executable not found, show explicit install guidance message and block completion of initialization. - -## Diff Behavior -- Diff views open in tabs in the editor area. -- Single-click on a changed file opens a diff in a transient tab (reused for subsequent single-clicks), matching post transient tab behavior. -- Double-click on a changed file opens a dedicated non-transient diff tab that remains open until explicitly closed by the user. -- Dedicated diff tabs stay open even when other files are clicked in the sidebar. -- Open diff tabs are persisted in app tab state and restored on next app start, same persistence model as post tabs. -- Clicking a changed file opens diff view of working tree vs repository (HEAD/index depending file state). -- Committing changes automatically closes all open diff tabs because the compared diff baseline no longer applies. - -## Polling Behavior -- Poll git status regularly (VS Code-like freshness). -- Refresh should be incremental (preserve list item identity/order strategy where possible). -- Preserve scroll position for large lists; avoid jump-to-top. - -## Remote Awareness -- If remote exists, perform regular `git fetch` polling. -- Show upstream relationship in version history section: - - current local HEAD - - upstream branch tip - - ahead/behind indicators - ---- - -## Proposed Architecture - -## Main Process (new engine) -Create `src/main/engine/GitEngine.ts` with focused methods: -- `checkAvailability(): Promise<{ gitFound: boolean; version?: string }>` -- `getRepoState(projectPath): Promise` -- `initializeRepo(projectPath): Promise` -- `getStatus(projectPath): Promise` -- `getDiff(projectPath, filePath): Promise` -- `getHistory(projectPath, limit, cursor?): Promise` -- `getRemoteState(projectPath): Promise` -- `fetch(projectPath): Promise` -- `pull(projectPath): Promise` -- `push(projectPath): Promise` -- `commitAll(projectPath, message): Promise` - -Implementation notes: -- Use `simple-git` instance rooted at active project path. -- Distinguish error classes: - - git missing - - not a repo - - auth/network/merge conflict - - detached HEAD / no upstream -- Normalize file paths to repo-relative format for renderer stability. - -## IPC Layer -Add handlers in `src/main/ipc/handlers.ts` and type contracts in shared API: -- `git:checkAvailability` -- `git:getRepoState` -- `git:init` -- `git:status` -- `git:diff` -- `git:history` -- `git:remoteState` -- `git:fetch` -- `git:pull` -- `git:push` -- `git:commitAll` - -Expose via `src/main/preload.ts`: -- `window.electronAPI.git.*` methods. - -## Renderer State -Extend `src/renderer/store/appStore.ts` with Git slice: -- `activeView` union includes `'git'` -- `git: {` - - `availability` - - `repoState` - - `status` (files + counts) - - `history` - - `remoteState` (branch, upstream, ahead, behind, lastFetchAt) - - `selectedDiffFile` - - `commitMessage` - - `loading/action flags` - - `error` -- `}` - -Store actions: -- `setGitStatus`, `mergeGitStatusIncremental`, `setGitHistory`, `setGitRemoteState` -- `setSelectedDiffFile`, `setCommitMessage` -- `setGitPollingState` - -Tab behavior extensions: -- Extend `TabType` with `'git-diff'`. -- Use existing transient tab mechanics for single-click diff open. -- Add/ensure explicit pinning path for double-click diff tabs (`isTransient: false`). -- Include diff tabs in persisted tab state (`getTabState` / `restoreTabState`) so they reopen after restart. -- On successful commit action, remove all open `'git-diff'` tabs and clear selected diff state. - -## Renderer Components -- Update `src/renderer/components/ActivityBar/ActivityBar.tsx` to place Git icon in the bottom rail group above Settings and wire view toggle. -- Add Git section to `src/renderer/components/Sidebar/Sidebar.tsx` render switch. -- Add dedicated presentational components: - - `src/renderer/components/GitSidebar/GitSidebar.tsx` - - `src/renderer/components/GitSidebar/OpenChangesList.tsx` - - `src/renderer/components/GitSidebar/VersionHistoryList.tsx` - - `src/renderer/components/GitSidebar/RepoActions.tsx` -- Add diff tab rendering in editor area: - - new tab type `'git-diff'` - - diff viewer component `GitDiffView`. - - single-click handler opens/reuses transient diff tab. - - double-click handler opens persistent diff tab. - ---- - -## Polling and Update Strategy - -## Status Polling (fast) -- Interval: ~2s when Git view visible, ~5–10s when hidden. -- Trigger immediate refresh after commit/fetch/pull/push/init. -- Use in-flight guard to avoid concurrent status calls. - -## Remote Polling (slower) -- Run only when remote exists and git is available. -- Interval: ~30–60s with backoff on errors. -- Use `git fetch --prune` equivalent through `simple-git`. - -## Scroll/Render Stability -- Keep stable `key` = repo-relative file path. -- Preserve existing array reference for unchanged items when merging updates. -- Update only changed/added/removed entries in store (incremental diff merge). -- Use virtualization (`react-window`) if list grows beyond threshold (e.g., >300 entries). -- Preserve scrollTop by storing/restoring container position if full list replacement is unavoidable. - -## Repositioning Rules -- Do not auto-sort on every tick if sort key not changed. -- Insert new items predictably (status-group + path order) to minimize movement. -- Never auto-scroll on status update. - ---- - -## History Model (lower half) - -Each history item should include: -- `commitHash` -- `author` -- `date` -- `subject` -- `isHead` -- `isRemoteHead` (where applicable) -- `refs` (branch/tag labels) - -Remote awareness section: -- Show `localBranch -> upstreamBranch` -- Show `ahead N / behind M` -- Show last fetch timestamp and fetch errors (if any) - ---- - -## Error Handling UX - -## Git Missing -- Detect once on startup/opening Git view and before actions. -- Display clear CTA text: install Git and restart app. - -## Not a Repo -- Show empty state with `Initialize Git` button. -- After successful init, auto-refresh status/history. - -## Action Failures -- Show concise toast + inline error in Git panel section. -- Keep previous state rendered (no hard reset). - -## Auth/Conflict Cases -- For pull/push conflicts/auth failures, show actionable message; do not hide current status/history. - ---- - -## Test-First Delivery Plan (TDD) - -Follow strict red-green-refactor per project rules. - -## Phase 1: Contracts and engine scaffolding -1. Add failing tests for `GitEngine` availability/repo detection/status parsing. -2. Implement minimal engine methods. -3. Add IPC contract tests for new `git:*` handlers. - -## Phase 2: No-repo + init workflow -1. Add renderer tests for no-repo state and `Initialize Git` button. -2. Implement init action and git-missing messaging. -3. Validate with integration-style IPC mock tests. - -## Phase 3: Open changes + diff -1. Add tests for open changes list rendering and file selection. -2. Add tests for opening `git-diff` tab and loading diff. -3. Add tests for single-click transient tab reuse and double-click persistent tab behavior. -4. Implement diff component and tab behavior. - -## Phase 3b: Diff tab persistence and commit cleanup -1. Add tests to verify `git-diff` tabs are persisted/restored via tab state. -2. Add tests to verify successful commit closes all open `git-diff` tabs. -3. Implement store and commit-flow wiring for cleanup behavior. - -## Phase 4: Commit + repo actions -1. Add tests for commit message entry and commit action (`addAll + commit`). -2. Add tests for fetch/pull/push button wiring and disabled/loading states. -3. Implement action handlers with refresh chaining. - -## Phase 5: Polling and stability -1. Add tests for polling intervals and in-flight guards (fake timers). -2. Add tests for incremental list updates preserving scroll/identity. -3. Implement merge strategy + optional virtualization threshold. - -## Phase 6: Remote tracking in history -1. Add tests for ahead/behind and upstream marker rendering. -2. Implement periodic fetch + remote state projection. -3. Validate remote indicators against mocked git responses. - -## Phase 7: Hardening -1. Add tests for error surfaces (git missing, auth fail, merge conflict). -2. Verify all tests pass. -3. Run full build and fix regressions. - ---- - -## Suggested File-Level Work Breakdown - -Main process: -- `src/main/engine/GitEngine.ts` (new) -- `src/main/engine/index.ts` (export) -- `src/main/ipc/handlers.ts` (new handlers) -- `src/main/shared/electronApi.ts` (API types) -- `src/main/preload.ts` (bridge methods) - -Renderer: -- `src/renderer/store/appStore.ts` (Git state/actions) -- `src/renderer/components/ActivityBar/ActivityBar.tsx` (Git icon entry in bottom rail, above Settings) -- `src/renderer/components/Sidebar/Sidebar.tsx` (Git view integration) -- `src/renderer/components/GitSidebar/*` (new) -- `src/renderer/components/Editor/*` or new `GitDiffView` component - -Tests: -- `tests/engine/GitEngine.test.ts` (new) -- `tests/ipc/handlers.test.ts` (extend) -- `tests/renderer/components/GitSidebar.test.tsx` (new) -- `tests/renderer/store/appStore.git.test.ts` (new) - ---- - -## Milestones and Acceptance Criteria - -## Milestone A: Basic Git UX -- Git sync icon appears in left sidebar rail bottom section above Settings. -- Git sidebar opens with no-repo empty state. -- `Initialize Git` works and transitions to repo state. - -## Milestone B: Changes + Diff -- Open Changes list renders tracked/untracked/modified/deleted files. -- Single-click opens/reuses transient diff tab and renders correct patch. -- Double-click opens persistent diff tab that remains until user closes it. -- Diff tabs persist across app restarts. - -## Milestone C: Commit and Repo Actions -- Commit message + Commit button performs add-all + commit. -- Successful commit closes all open diff tabs automatically. -- Fetch/Pull/Push actions execute with visible status feedback. - -## Milestone D: Polling + Remote -- Status polling updates changes without scroll jump. -- Remote fetch polling updates ahead/behind and remote markers. -- History clearly shows local/remote relation. - -## Milestone E: Quality Gate -- All tests pass. -- Full build passes. -- No console spam, no renderer freeze on large change sets. - ---- - -## Implementation Order Recommendation -1. Contracts + GitEngine + IPC -2. No-repo/init UX -3. Open changes list + diff viewer -4. Commit + fetch/pull/push actions -5. Polling + incremental list merge + scroll stability -6. Remote-aware history refinement and hardening - -This order minimizes risk and delivers user-visible value early while preserving room for performance optimization in later iterations. diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index 91a5132..5ca28cd 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -835,5 +835,25 @@ describe('GitEngine', () => { expect.stringContaining('repository'), ])); }); + + it('should classify pull merge conflicts with conflict code', async () => { + mockPull.mockRejectedValue(new Error('CONFLICT (content): Merge conflict in posts/first.md')); + + const result = await gitEngine.pull('/tmp/project'); + + expect(result.success).toBe(false); + expect(result.code).toBe('conflict'); + expect(result.error).toContain('Merge conflict'); + }); + + it('should classify fetch connectivity issues with network code', async () => { + mockFetch.mockRejectedValue(new Error('fatal: unable to access https://example.com/repo.git: Could not resolve host')); + + const result = await gitEngine.fetch('/tmp/project'); + + expect(result.success).toBe(false); + expect(result.code).toBe('network'); + expect(result.error).toContain('Could not resolve host'); + }); }); }); diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index d8a81d9..950cbc9 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -64,6 +64,15 @@ describe('GitSidebar', () => { expect(await screen.findByRole('button', { name: /initialize git/i })).toBeInTheDocument(); }); + it('shows install guidance when git executable is missing', async () => { + (window as any).electronAPI.git.checkAvailability = vi.fn().mockResolvedValue({ gitFound: false }); + + render(); + + expect(await screen.findByText(/git executable not found/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /initialize git/i })).toBeInTheDocument(); + }); + it('renders open changes list when repository exists', async () => { (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ isRepo: true, @@ -608,6 +617,46 @@ describe('GitSidebar', () => { expect(screen.getByText(/retry with username \+ token/i)).toBeInTheDocument(); }); + it('shows merge conflict action error while keeping existing changes and history visible', async () => { + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.getStatus = vi.fn().mockResolvedValue({ + files: [{ path: 'posts/first.md', status: 'modified' }], + counts: { untracked: 0, modified: 1, deleted: 0, renamed: 0, staged: 0, total: 1 }, + }); + (window as any).electronAPI.git.getHistory = vi.fn().mockResolvedValue([ + { + hash: 'abc123', + shortHash: 'abc123', + date: '2026-02-16T10:00:00.000Z', + subject: 'feat: existing history', + author: 'Dev One', + }, + ]); + (window as any).electronAPI.git.pull = vi.fn().mockResolvedValue({ + success: false, + code: 'conflict', + error: 'CONFLICT (content): Merge conflict in posts/first.md', + }); + + render(); + + expect(await screen.findByText('posts/first.md')).toBeInTheDocument(); + expect(screen.getByText(/feat: existing history/i)).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /pull/i })); + }); + + expect(await screen.findByText(/merge conflict/i)).toBeInTheDocument(); + expect(screen.getByText('posts/first.md')).toBeInTheDocument(); + expect(screen.getByText(/feat: existing history/i)).toBeInTheDocument(); + }); + it('shows in-progress feedback while push is running', async () => { let resolvePush: ((value: { success: boolean }) => void) | null = null; (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({