From 2525c58e2c878645861c7cb7218e3621dc8b764d Mon Sep 17 00:00:00 2001 From: Ramin Tadayon Date: Fri, 3 Oct 2025 14:29:35 -0700 Subject: [PATCH] Adds support for composing without a base commit --- src/env/node/git/git.ts | 4 +- src/env/node/git/sub-providers/patch.ts | 23 ++++++- src/env/node/git/sub-providers/staging.ts | 40 ++++++------ src/git/gitProvider.ts | 5 +- src/git/models/revision.ts | 2 + .../apps/plus/composer/components/app.ts | 1 + .../plus/composer/components/commit-item.ts | 5 +- .../plus/composer/components/commits-panel.ts | 34 +++++++--- .../plus/composer/components/composer.css.ts | 9 +++ src/webviews/plus/composer/composerWebview.ts | 62 +++++++++---------- src/webviews/plus/composer/protocol.ts | 23 +++---- src/webviews/plus/composer/utils.ts | 10 +-- 12 files changed, 127 insertions(+), 91 deletions(-) diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index 493a74da6a5e8..63b0c9bcdeee1 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -34,6 +34,7 @@ import { } from '../../../git/errors'; import type { GitDir } from '../../../git/gitProvider'; import type { GitDiffFilter } from '../../../git/models/diff'; +import { rootSha } from '../../../git/models/revision'; import { parseGitRemoteUrl } from '../../../git/parsers/remoteParser'; import { isUncommitted, isUncommittedStaged, shortenRevision } from '../../../git/utils/revision.utils'; import { getCancellationTokenId } from '../../../system/-webview/cancellation'; @@ -69,9 +70,6 @@ export const maxGitCliLength = 30000; const textDecoder = new TextDecoder('utf8'); -// This is a root sha of all git repo's if using sha1 -const rootSha = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - export const GitErrors = { alreadyCheckedOut: /already checked out/i, alreadyExists: /already exists/i, diff --git a/src/env/node/git/sub-providers/patch.ts b/src/env/node/git/sub-providers/patch.ts index c5460f4d9f4b3..f731e7d7adddc 100644 --- a/src/env/node/git/sub-providers/patch.ts +++ b/src/env/node/git/sub-providers/patch.ts @@ -177,7 +177,7 @@ export class PatchGitSubProvider implements GitPatchSubProvider { @log({ args: { 2: p => p.length } }) async createUnreachableCommitsFromPatches( repoPath: string, - base: string, + base: string | undefined, patches: { message: string; patch: string }[], ): Promise { // Create a temporary index file @@ -198,7 +198,7 @@ export class PatchGitSubProvider implements GitPatchSubProvider { private async createUnreachableCommitForPatchCore( env: Record, repoPath: string, - base: string, + base: string | undefined, message: string, patch: string, ): Promise { @@ -222,7 +222,14 @@ export class PatchGitSubProvider implements GitPatchSubProvider { const tree = result.stdout.trim(); // Create new commit from the tree - result = await this.git.exec({ cwd: repoPath, env: env }, 'commit-tree', tree, '-p', base, '-m', message); + result = await this.git.exec( + { cwd: repoPath, env: env }, + 'commit-tree', + tree, + ...(base ? ['-p', base] : []), + '-m', + message, + ); const sha = result.stdout.trim(); return sha; @@ -234,6 +241,16 @@ export class PatchGitSubProvider implements GitPatchSubProvider { } } + async createEmptyInitialCommit(repoPath: string): Promise { + const emptyTree = await this.git.exec({ cwd: repoPath }, 'hash-object', '-t', 'tree', '/dev/null'); + const result = await this.git.exec({ cwd: repoPath }, 'commit-tree', emptyTree.stdout.trim(), '-m', 'temp'); + // create ref/heaads/main and point to it + await this.git.exec({ cwd: repoPath }, 'update-ref', 'refs/heads/main', result.stdout.trim()); + // point HEAD to the branch + await this.git.exec({ cwd: repoPath }, 'symbolic-ref', 'HEAD', 'refs/heads/main'); + return result.stdout.trim(); + } + @log({ args: { 1: false } }) async validatePatch(repoPath: string | undefined, contents: string): Promise { try { diff --git a/src/env/node/git/sub-providers/staging.ts b/src/env/node/git/sub-providers/staging.ts index b247ab6ed3693..ba76c69d1616d 100644 --- a/src/env/node/git/sub-providers/staging.ts +++ b/src/env/node/git/sub-providers/staging.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'fs'; import { tmpdir } from 'os'; import type { Uri } from 'vscode'; import type { Container } from '../../../../container'; +import { GitErrorHandling } from '../../../../git/commandOptions'; import type { DisposableTemporaryGitIndex, GitStagingSubProvider } from '../../../../git/gitProvider'; import { splitPath } from '../../../../system/-webview/path'; import { log } from '../../../../system/decorators/log'; @@ -18,7 +19,7 @@ export class StagingGitSubProvider implements GitStagingSubProvider { ) {} @log() - async createTemporaryIndex(repoPath: string, base: string): Promise { + async createTemporaryIndex(repoPath: string, base: string | undefined): Promise { // Create a temporary index file const tempDir = await fs.mkdtemp(joinPaths(tmpdir(), 'gl-')); const tempIndex = joinPaths(tempDir, 'index'); @@ -37,24 +38,27 @@ export class StagingGitSubProvider implements GitStagingSubProvider { const env = { GIT_INDEX_FILE: tempIndex }; // Create the temp index file from a base ref/sha + if (base) { + // Get the tree of the base + const newIndexResult = await this.git.exec( + { cwd: repoPath, env: env }, + 'ls-tree', + '-z', + '-r', + '--full-name', + base, + ); - // Get the tree of the base - const newIndexResult = await this.git.exec( - { cwd: repoPath, env: env }, - 'ls-tree', - '-z', - '-r', - '--full-name', - base, - ); - - // Write the tree to our temp index - await this.git.exec( - { cwd: repoPath, env: env, stdin: newIndexResult.stdout }, - 'update-index', - '-z', - '--index-info', - ); + if (newIndexResult.stdout.trim()) { + // Write the tree to our temp index + await this.git.exec( + { cwd: repoPath, env: env, stdin: newIndexResult.stdout }, + 'update-index', + '-z', + '--index-info', + ); + } + } return mixinAsyncDisposable({ path: tempIndex, env: { GIT_INDEX_FILE: tempIndex } }, dispose); } catch (ex) { diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index 462e3af94b83a..2b5ee70cddaba 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -534,9 +534,10 @@ export interface GitPatchSubProvider { ): Promise; createUnreachableCommitsFromPatches( repoPath: string, - base: string, + base: string | undefined, patches: { message: string; patch: string }[], ): Promise; + createEmptyInitialCommit(repoPath: string): Promise; validatePatch(repoPath: string | undefined, contents: string): Promise; } @@ -651,7 +652,7 @@ export interface DisposableTemporaryGitIndex extends UnifiedAsyncDisposable { } export interface GitStagingSubProvider { - createTemporaryIndex(repoPath: string, base: string): Promise; + createTemporaryIndex(repoPath: string, base: string | undefined): Promise; stageFile(repoPath: string, pathOrUri: string | Uri): Promise; stageFiles(repoPath: string, pathOrUri: string[] | Uri[]): Promise; stageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise; diff --git a/src/git/models/revision.ts b/src/git/models/revision.ts index b08eeeea5bce9..1cabf515f05b4 100644 --- a/src/git/models/revision.ts +++ b/src/git/models/revision.ts @@ -1,6 +1,8 @@ export const deletedOrMissing = '0000000000000000000000000000000000000000-'; export const uncommitted = '0000000000000000000000000000000000000000'; export const uncommittedStaged = '0000000000000000000000000000000000000000:'; +// This is a root sha of all git repo's if using sha1 +export const rootSha = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; export type GitRevisionRange = | `${GitRevisionRangeNotation}${string}` diff --git a/src/webviews/apps/plus/composer/components/app.ts b/src/webviews/apps/plus/composer/components/app.ts index 26dd71ed431f8..1bd1d2526d4d1 100644 --- a/src/webviews/apps/plus/composer/components/app.ts +++ b/src/webviews/apps/plus/composer/components/app.ts @@ -1630,6 +1630,7 @@ export class ComposerApp extends LitElement { .canGenerateCommitsWithAI=${this.canGenerateCommitsWithAI} .isPreviewMode=${this.isPreviewMode} .baseCommit=${this.state.baseCommit} + .repoName=${this.state.baseCommit?.repoName ?? this.state.repositoryState?.current.name} .customInstructions=${this.customInstructions} .hasUsedAutoCompose=${this.state.hasUsedAutoCompose} .hasChanges=${this.state.hasChanges} diff --git a/src/webviews/apps/plus/composer/components/commit-item.ts b/src/webviews/apps/plus/composer/components/commit-item.ts index ba6a141a3542a..fe7ae54219d31 100644 --- a/src/webviews/apps/plus/composer/components/commit-item.ts +++ b/src/webviews/apps/plus/composer/components/commit-item.ts @@ -86,6 +86,9 @@ export class CommitItem extends LitElement { @property({ type: Boolean }) first = false; + @property({ type: Boolean }) + last = false; + override connectedCallback() { super.connectedCallback?.(); // Set the data attribute for sortable access @@ -120,7 +123,7 @@ export class CommitItem extends LitElement {
-
+
-
${this.baseCommit?.message || 'HEAD'}
+
+ ${this.baseCommit?.message || 'No commits yet'} +
- ${this.baseCommit?.repoName || 'Repository'} - / - ${this.baseCommit?.branchName || 'main'} + ${this.repoName || 'Repository'} + ${this.baseCommit?.branchName + ? html`/${this.baseCommit.branchName}` + : ''}
@@ -1279,6 +1288,7 @@ export class CommitsPanel extends LitElement { .multiSelected=${this.selectedCommitIds.has(commit.id)} .isPreviewMode=${this.isPreviewMode} ?first=${i === 0} + ?last=${i === this.commits.length - 1 && !this.baseCommit} @click=${(e: MouseEvent) => this.dispatchCommitSelect(commit.id, e)} @keydown=${(e: KeyboardEvent) => this.dispatchCommitSelect(commit.id, e)} > @@ -1289,13 +1299,17 @@ export class CommitsPanel extends LitElement {
-
+
-
${this.baseCommit?.message || 'HEAD'}
+
+ ${this.baseCommit?.message || 'No commits yet'} +
- ${this.baseCommit?.repoName || 'Repository'} - / - ${this.baseCommit?.branchName || 'main'} + ${this.repoName || 'Repository'} + ${this.baseCommit?.branchName + ? html`/${this.baseCommit.branchName}` + : ''}
diff --git a/src/webviews/apps/plus/composer/components/composer.css.ts b/src/webviews/apps/plus/composer/components/composer.css.ts index 8011465b1a501..6d943fd238d0a 100644 --- a/src/webviews/apps/plus/composer/components/composer.css.ts +++ b/src/webviews/apps/plus/composer/components/composer.css.ts @@ -148,6 +148,10 @@ export const composerItemCommitStyles = css` height: 50%; } + .composer-item.is-last .composer-item__commit::before { + display: none; + } + .composer-item__commit::after { content: ''; position: absolute; @@ -168,6 +172,11 @@ export const composerItemCommitStyles = css` .composer-item.is-base .composer-item__commit::before { border-left-style: solid; } + + .composer-item__commit.is-empty::before, + .composer-item__commit.is-empty::after { + display: none; + } `; export const composerItemContentStyles = css` diff --git a/src/webviews/plus/composer/composerWebview.ts b/src/webviews/plus/composer/composerWebview.ts index 00a89ad1f4d60..3d4f67047a544 100644 --- a/src/webviews/plus/composer/composerWebview.ts +++ b/src/webviews/plus/composer/composerWebview.ts @@ -10,6 +10,7 @@ import type { RepositoryFileSystemChangeEvent, } from '../../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; +import { rootSha } from '../../../git/models/revision'; import { sendFeedbackEvent, showUnhelpfulFeedbackPicker } from '../../../plus/ai/aiFeedbackUtils'; import type { AIModelChangeEvent } from '../../../plus/ai/aiProviderService'; import { getRepositoryPickerTitleAndPlaceholder, showRepositoryPicker } from '../../../quickpicks/repositoryPicker'; @@ -311,30 +312,7 @@ export class ComposerWebviewProvider implements WebviewProvider = { hunks: [], commits: [], hunkMap: [], - baseCommit: { - sha: '', - message: '', - repoName: '', - branchName: '', - }, + baseCommit: null, safetyState: { repoPath: '', - headSha: '', + headSha: null, hashes: { staged: null, unstaged: null, @@ -361,7 +356,7 @@ export interface GenerateWithAIParams { export interface DidChangeComposerDataParams { hunks: ComposerHunk[]; commits: ComposerCommit[]; - baseCommit: ComposerBaseCommit; + baseCommit: ComposerBaseCommit | null; } // IPC Commands and Notifications @@ -453,7 +448,7 @@ export interface GenerateCommitsParams { hunks: ComposerHunk[]; commits: ComposerCommit[]; hunkMap: ComposerHunkMap[]; - baseCommit: ComposerBaseCommit; + baseCommit: ComposerBaseCommit | null; customInstructions?: string; isRecompose?: boolean; } @@ -467,7 +462,7 @@ export interface GenerateCommitMessageParams { export interface FinishAndCommitParams { commits: ComposerCommit[]; hunks: ComposerHunk[]; - baseCommit: ComposerBaseCommit; + baseCommit: ComposerBaseCommit | null; safetyState: ComposerSafetyState; } @@ -481,7 +476,7 @@ export interface DidChangeComposerDataParams { hunks: ComposerHunk[]; commits: ComposerCommit[]; hunkMap: ComposerHunkMap[]; - baseCommit: ComposerBaseCommit; + baseCommit: ComposerBaseCommit | null; } export interface DidGenerateCommitsParams { @@ -510,7 +505,7 @@ export interface DidReloadComposerParams { hunks: ComposerHunk[]; commits: ComposerCommit[]; hunkMap: ComposerHunkMap[]; - baseCommit: ComposerBaseCommit; + baseCommit: ComposerBaseCommit | null; safetyState: ComposerSafetyState; loadingError: string | null; hasChanges: boolean; diff --git a/src/webviews/plus/composer/utils.ts b/src/webviews/plus/composer/utils.ts index a1d708a6a3465..44fcb4cea610b 100644 --- a/src/webviews/plus/composer/utils.ts +++ b/src/webviews/plus/composer/utils.ts @@ -335,15 +335,11 @@ export async function getWorkingTreeDiffs(repo: Repository): Promise { - if (!headSha) { - throw new Error('Cannot create safety state: no HEAD commit found'); - } - return { repoPath: repo.path, - headSha: headSha, + headSha: headSha ?? null, hashes: { staged: diffs.staged?.contents ? await sha256(diffs.staged.contents) : null, unstaged: diffs.unstaged?.contents ? await sha256(diffs.unstaged.contents) : null, @@ -371,7 +367,7 @@ export async function validateSafetyState( // 2. Check HEAD SHA const currentHeadCommit = await repo.git.commits.getCommit('HEAD'); - const currentHeadSha = currentHeadCommit?.sha ?? 'unknown'; + const currentHeadSha = currentHeadCommit?.sha ?? null; if (currentHeadSha !== safetyState.headSha) { errors.push(`HEAD commit changed from "${safetyState.headSha}" to "${currentHeadSha}"`); }