@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
183 lines • 8.87 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
/**
* Git Reset Tool
*
* Reset/undo operations: soft, mixed, hard.
*/
import { Box, Text } from 'ink';
import React from 'react';
import { getCurrentMode } from '../../context/mode-context.js';
import { useTheme } from '../../hooks/useTheme.js';
import { jsonSchema, tool } from '../../types/core.js';
import { execGit, getCommits, getDiffStats, parseGitStatus, } from './utils.js';
// ============================================================================
// Preview
// ============================================================================
async function getResetPreview(args) {
const target = args.target || 'HEAD';
// If resetting to HEAD, no commits affected
if (target === 'HEAD' && !args.file) {
// Just show currently staged changes
const statusOutput = await execGit(['status', '--porcelain']);
const { staged, unstaged } = parseGitStatus(statusOutput);
const stagedStats = await getDiffStats(true);
let additions = 0;
let deletions = 0;
for (const [, stats] of stagedStats) {
additions += stats.additions;
deletions += stats.deletions;
}
return {
commitsAffected: [],
filesAffected: staged.length + unstaged.length,
additions,
deletions,
};
}
// Get commits between target and HEAD
try {
const commits = await getCommits({ range: `${target}..HEAD` });
// Get diff stats between target and HEAD
const diffOutput = await execGit(['diff', '--numstat', target, 'HEAD']);
let additions = 0;
let deletions = 0;
let filesAffected = 0;
for (const line of diffOutput.split('\n')) {
if (!line.trim())
continue;
const parts = line.split('\t');
if (parts.length >= 3) {
additions += parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
deletions += parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
filesAffected++;
}
}
return { commitsAffected: commits, filesAffected, additions, deletions };
}
catch {
return { commitsAffected: [], filesAffected: 0, additions: 0, deletions: 0 };
}
}
// ============================================================================
// Execution
// ============================================================================
const executeGitReset = async (args) => {
try {
const target = args.target || 'HEAD';
// File-specific reset (ignores mode)
if (args.file) {
await execGit(['checkout', target, '--', args.file]);
return `Reset '${args.file}' to ${target}`;
}
// Get preview for output
const preview = await getResetPreview(args);
// Build git command
const gitArgs = ['reset', `--${args.mode}`, target];
await execGit(gitArgs);
const lines = [];
lines.push(`Reset to ${target} (${args.mode})`);
lines.push('');
switch (args.mode) {
case 'soft':
lines.push('Changes kept in staging area.');
break;
case 'mixed':
lines.push('Changes moved to working tree (unstaged).');
break;
case 'hard':
lines.push('All changes discarded.');
break;
}
if (preview.commitsAffected.length > 0) {
lines.push('');
lines.push(`Commits affected: ${preview.commitsAffected.length}`);
for (const commit of preview.commitsAffected.slice(0, 5)) {
lines.push(` ${commit.shortHash} ${commit.subject}`);
}
if (preview.commitsAffected.length > 5) {
lines.push(` ... and ${preview.commitsAffected.length - 5} more`);
}
}
if (preview.filesAffected > 0) {
lines.push('');
lines.push(`Files affected: ${preview.filesAffected} (+${preview.additions}, -${preview.deletions})`);
}
return lines.join('\n');
}
catch (error) {
return `Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
};
// ============================================================================
// Tool Definition
// ============================================================================
const gitResetCoreTool = tool({
description: 'Reset repository state. soft=keep staged, mixed=unstage changes, hard=discard all changes (DANGEROUS).',
inputSchema: jsonSchema({
type: 'object',
properties: {
mode: {
type: 'string',
enum: ['soft', 'mixed', 'hard'],
description: 'Reset mode: soft (keep staged), mixed (unstage), hard (discard all)',
},
target: {
type: 'string',
description: 'Target commit/ref to reset to (default: HEAD). Examples: HEAD~1, main, abc1234',
},
file: {
type: 'string',
description: 'Reset a specific file only (uses checkout)',
},
},
required: ['mode'],
}),
// Approval varies by mode
needsApproval: (args) => {
const mode = getCurrentMode();
// ALWAYS_APPROVE for hard reset (permanent data loss)
if (args.mode === 'hard') {
return true;
}
// STANDARD for soft and mixed
return mode === 'normal';
},
execute: async (args, _options) => {
return await executeGitReset(args);
},
});
// ============================================================================
// Formatter
// ============================================================================
function GitResetFormatter({ args, result, }) {
const { colors } = useTheme();
const [preview, setPreview] = React.useState(null);
// Load preview before execution
React.useEffect(() => {
if (!result) {
getResetPreview(args)
.then(setPreview)
.catch(() => { });
}
}, [args, result]);
const target = args.target || 'HEAD';
// Mode descriptions
const modeDescriptions = {
soft: 'Keep changes staged',
mixed: 'Unstage changes, keep in working tree',
hard: 'Discard all changes permanently',
};
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: colors.tool, children: "\u2692 git_reset" }), args.mode === 'hard' && (_jsx(Box, { children: _jsx(Text, { color: colors.error, children: "This will permanently discard uncommitted work!" }) })), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Mode: " }), _jsx(Text, { color: args.mode === 'hard' ? colors.error : colors.text, children: args.mode }), _jsxs(Text, { color: colors.secondary, children: [" - ", modeDescriptions[args.mode]] })] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Target: " }), _jsx(Text, { color: colors.primary, children: target })] }), args.file && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "File: " }), _jsx(Text, { color: colors.text, children: args.file })] })), preview && !args.file && (_jsxs(_Fragment, { children: [preview.commitsAffected.length > 0 && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Commits affected: " }), _jsx(Text, { color: colors.warning, children: preview.commitsAffected.length })] })), preview.filesAffected > 0 && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Files affected: " }), _jsxs(Text, { color: colors.text, children: [preview.filesAffected, " "] }), _jsxs(Text, { color: colors.success, children: ["(+", preview.additions] }), _jsx(Text, { color: colors.text, children: ", " }), _jsxs(Text, { color: colors.error, children: ["-", preview.deletions] }), _jsx(Text, { color: colors.text, children: ")" })] }))] })), result?.includes('Reset to') && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.success, children: "\u2713 Reset completed" }) })), result?.includes('Error:') && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.error, children: ["\u2717 ", result] }) }))] }));
}
const formatter = (args, result) => {
return _jsx(GitResetFormatter, { args: args, result: result });
};
// ============================================================================
// Export
// ============================================================================
export const gitResetTool = {
name: 'git_reset',
tool: gitResetCoreTool,
formatter,
};
//# sourceMappingURL=git-reset.js.map