Skip to content
Closed
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
1,585 changes: 1,126 additions & 459 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,16 @@
"typescript-eslint": "^8.30.1",
"vitest": "^3.2.4",
"yargs": "^17.7.2"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.203.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0",
"@opentelemetry/instrumentation-http": "^0.203.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-node": "^0.203.0",
"diff": "^8.0.2",
"google-auth-library": "^10.2.0"
}
Comment on lines +88 to 98
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

These OpenTelemetry dependencies appear to be unrelated to the Git Assistant feature being added in this pull request. I couldn't find any usage of them in the new or modified code. Adding unused dependencies increases the application's size, complexity, and potential security surface. If these are not required for the git functionality, they should be removed from this PR and potentially added in a separate one if they are for another feature.

  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.17.0",
    "diff": "^8.0.2",
    "google-auth-library": "^10.2.0"
  }

Copy link
Contributor Author

@ipapapa ipapapa Aug 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OpenTelemetry dependencies mentioned are pre-existing in the Gemini CLI project and were not added by this PR. These dependencies were introduced in PR #762 as part of the "OpenTelemetry Integration & Telemetry Control Flag" feature. This PR only adds the Smart Git Workflow Assistant feature and does not modify any package.json files or introduce new dependencies. No dependency changes were made.

It just happened that this PR is an evolution of this PR #4904 that makes dependency updates.

}
216 changes: 215 additions & 1 deletion packages/cli/src/ui/hooks/atCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ import {
getErrorMessage,
isNodeError,
unescapePath,
isGitRepository,
findGitRoot,
} from '@google/gemini-cli-core';
import {
HistoryItem,
IndividualToolCallDisplay,
ToolCallStatus,
} from '../types.js';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
import { simpleGit } from 'simple-git';


interface HandleAtCommandParams {
query: string;
Expand All @@ -34,11 +38,188 @@ interface HandleAtCommandResult {
shouldProceed: boolean;
}

interface GitContentEntry {
atPath: string;
content: string;
description: string;
}

/**
* Adds git content to processed query parts
*/
function addGitContentToQuery(
processedQueryParts: PartUnion[],
gitContentEntries: GitContentEntry[],
atPathToResolvedSpecMap: Map<string, string>,
): void {
if (gitContentEntries.length > 0) {
processedQueryParts.push({ text: '\n--- Git Context ---' });
for (const entry of gitContentEntries) {
const resolvedSpec = atPathToResolvedSpecMap.get(entry.atPath);
processedQueryParts.push({
text: `\n${resolvedSpec}:\n${entry.content}\n`,
});
}
processedQueryParts.push({ text: '\n--- End of git context ---' });
}
}

interface AtCommandPart {
type: 'text' | 'atPath';
content: string;
}

interface GitAtCommandResult {
success: boolean;
content?: string;
description?: string;
error?: string;
}

/**
* Handles @git commands by extracting git context information
*/
async function handleGitAtCommand(
gitCommand: string,
config: Config,
userMessageTimestamp: number,
addItem: UseHistoryManagerReturn['addItem'],
signal: AbortSignal,
): Promise<GitAtCommandResult> {
try {
// Check if we're in a git repository
if (!isGitRepository(config.getTargetDir())) {
return {
success: false,
error: 'Not in a git repository',
};
}

const gitRoot = findGitRoot(config.getTargetDir());
if (!gitRoot) {
return {
success: false,
error: 'Could not find git repository root',
};
}

const git = simpleGit(gitRoot);

// Parse the git command
if (gitCommand === 'git') {
// Default: show git status
const status = await git.status();
const content = `Git Status:
- Branch: ${status.current}
- Staged: ${status.staged.length} files
- Modified: ${status.modified.length} files
- Untracked: ${status.not_added.length} files
${status.ahead > 0 ? `- Ahead: ${status.ahead} commits` : ''}
${status.behind > 0 ? `- Behind: ${status.behind} commits` : ''}`;

return {
success: true,
content,
description: 'status',
};
}

const parts = gitCommand.split(':');
if (parts.length < 2) {
return {
success: false,
error: 'Invalid git command format. Use @git:command or @git:command:args',
};
}

const command = parts[1];
const args = parts.slice(2).join(':');

switch (command) {
case 'diff': {
let diff;
if (args) {
// git diff with specific arguments (e.g., @git:diff:main)
diff = await git.diff([args]);
} else {
// Default: show working directory diff
diff = await git.diff();
}
return {
success: true,
content: diff || 'No differences found',
description: `diff${args ? ` ${args}` : ''}`,
};
}

case 'log': {
const count = args ? parseInt(args, 10) || 5 : 5;
const log = await git.log({ maxCount: count });
const content = log.all
.map((commit, index) => {
const date = new Date(commit.date).toLocaleDateString();
return `${index + 1}. ${commit.message} - ${commit.author_name} (${date}) [${commit.hash.substring(0, 8)}]`;
})
.join('\n');

return {
success: true,
content: `Recent commits:\n${content}`,
description: `log (${count} commits)`,
};
}

case 'status': {
const status = await git.status();
const content = `Git Status:
- Branch: ${status.current}
- Staged files: ${status.staged.join(', ') || 'none'}
- Modified files: ${status.modified.join(', ') || 'none'}
- Untracked files: ${status.not_added.join(', ') || 'none'}
${status.ahead > 0 ? `- Ahead: ${status.ahead} commits` : ''}
${status.behind > 0 ? `- Behind: ${status.behind} commits` : ''}`;

return {
success: true,
content,
description: 'detailed status',
};
}

case 'branch': {
const branches = await git.branch();
const content = `Current branch: ${branches.current}\nAll branches:\n${branches.all.map(b => b === branches.current ? `* ${b}` : ` ${b}`).join('\n')}`;

return {
success: true,
content,
description: 'branches',
};
}

case 'staged': {
const diff = await git.diff(['--cached']);
return {
success: true,
content: diff || 'No staged changes',
description: 'staged changes',
};
}

default:
return {
success: false,
error: `Unknown git command: ${command}. Supported: diff, log, status, branch, staged`,
};
}
} catch (error) {
return {
success: false,
error: getErrorMessage(error),
};
}
}

/**
* Parses a query string to find all '@<path>' commands and text segments.
* Handles \ escaped spaces within paths.
Expand Down Expand Up @@ -142,6 +323,7 @@ export async function handleAtCommand({
const pathSpecsToRead: string[] = [];
const atPathToResolvedSpecMap = new Map<string, string>();
const contentLabelsForDisplay: string[] = [];
const gitContentEntries: GitContentEntry[] = [];
const ignoredByReason: Record<string, string[]> = {
git: [],
gemini: [],
Expand Down Expand Up @@ -186,6 +368,27 @@ export async function handleAtCommand({
return { processedQuery: null, shouldProceed: false };
}

// Handle special @git commands
if (pathName.startsWith('git:') || pathName === 'git') {
const gitResult = await handleGitAtCommand(pathName, config, userMessageTimestamp, addItem, signal);
if (gitResult.success) {
// Mark this as a special git command that should be processed later
atPathToResolvedSpecMap.set(originalAtPath, `git:${gitResult.description}`);
// Store git content locally for later use
gitContentEntries.push({
atPath: originalAtPath,
content: gitResult.content || '',
description: gitResult.description || '',
});
contentLabelsForDisplay.push(`git:${gitResult.description}`);
continue;
} else {
// Git command failed, skip but continue with other commands
onDebugMessage(`Git @ command '${pathName}' failed: ${gitResult.error}`);
continue;
}
}

// Check if path should be ignored based on filtering options

const gitIgnored =
Expand Down Expand Up @@ -369,9 +572,17 @@ export async function handleAtCommand({
onDebugMessage(message);
}

// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
// Handle case where we have git content but no files to read
if (pathSpecsToRead.length === 0) {
onDebugMessage('No valid file paths found in @ commands to read.');

// Check if we have git content to add
if (gitContentEntries.length > 0) {
const processedQueryParts: PartUnion[] = [{ text: initialQueryText }];
addGitContentToQuery(processedQueryParts, gitContentEntries, atPathToResolvedSpecMap);
return { processedQuery: processedQueryParts, shouldProceed: true };
}

if (initialQueryText === '@' && query.trim() === '@') {
// If the only thing was a lone @, pass original query (which might have spaces)
return { processedQuery: [{ text: query }], shouldProceed: true };
Expand Down Expand Up @@ -441,6 +652,9 @@ export async function handleAtCommand({
);
}

// Add git content if any
addGitContentToQuery(processedQueryParts, gitContentEntries, atPathToResolvedSpecMap);

addItem(
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
HistoryItem,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
GEMINI_CONFIG_DIR as GEMINI_DIR,
} from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { GitAssistantTool } from '../tools/git-assistant.js';
import { GeminiClient } from '../core/client.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { GitService } from '../services/gitService.js';
Expand Down Expand Up @@ -644,6 +645,7 @@ export class Config {
registerCoreTool(ShellTool, this);
registerCoreTool(MemoryTool);
registerCoreTool(WebSearchTool, this);
registerCoreTool(GitAssistantTool, this);

await registry.discoverAllTools();
return registry;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export * from './utils/retry.js';
export * from './utils/systemEncoding.js';
export * from './utils/textUtils.js';
export * from './utils/formatters.js';
export * from './utils/gitUtils.js';

// Export services
export * from './services/fileDiscoveryService.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/diffOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import * as Diff from 'diff';

export const DEFAULT_DIFF_OPTIONS: Diff.PatchOptions = {
export const DEFAULT_DIFF_OPTIONS: Diff.CreatePatchOptionsNonabortable = {
context: 3,
ignoreWhitespace: true,
};
4 changes: 2 additions & 2 deletions packages/core/src/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ Expectation for required parameters:
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
) ?? '';
const confirmationDetails: ToolEditConfirmationDetails = {
type: 'edit',
title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.config.getTargetDir()))}`,
Expand Down Expand Up @@ -412,7 +412,7 @@ Expectation for required parameters:
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
) ?? '';
displayResult = {
fileDiff,
fileName,
Expand Down
Loading