@abhaydesu/git-pilot 1.3.1 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -66
- package/bin/git-pilot.js +8 -2
- package/package.json +3 -3
- package/src/cli.js +105 -9
- package/src/commands/auth.js +132 -0
- package/src/commands/branch.js +23 -8
- package/src/commands/commit.js +209 -26
- package/src/commands/run.js +36 -7
- package/src/commands/setup.js +208 -0
- package/src/commands/undo.js +141 -11
- package/src/lib/config.js +191 -0
- package/src/lib/credentials.js +498 -0
- package/src/lib/exec.js +10 -3
- package/src/lib/git.js +131 -9
- package/src/lib/prompts.js +72 -0
- package/src/lib/provider.js +353 -0
- package/src/lib/sanitizer.js +37 -0
- package/src/lib/scanner.js +74 -0
- package/src/lib/ui.js +112 -14
- package/src/lib/api.js +0 -59
package/src/commands/undo.js
CHANGED
|
@@ -1,35 +1,165 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { callApi, requireString } from "../lib/api.js";
|
|
3
2
|
import { parseGitCommand, runGit } from "../lib/exec.js";
|
|
4
3
|
import * as git from "../lib/git.js";
|
|
5
4
|
import { spin, showBlock, confirm, success, aborted, handleError } from "../lib/ui.js";
|
|
6
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Parses a standard Git reflog line (`<sha> HEAD@{<n>}: <action>: <detail>`)
|
|
8
|
+
* into `{ action, detail }`, or returns `null` if the line does not match.
|
|
9
|
+
* Prevents substring false-positives when commit messages or branch names
|
|
10
|
+
* contain words like "merge" or "rebase".
|
|
11
|
+
*/
|
|
12
|
+
export function parseReflogAction(line) {
|
|
13
|
+
if (typeof line !== "string" || !line.trim()) return null;
|
|
14
|
+
const match = /(?:^|\s)HEAD@\{\d+\}:\s*([^:]+):\s*(.*)$/.exec(line.trim());
|
|
15
|
+
if (!match) return null;
|
|
16
|
+
return {
|
|
17
|
+
action: match[1].trim(),
|
|
18
|
+
detail: match[2].trim(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ACTIVE_OPERATION_SUGGESTIONS = {
|
|
23
|
+
rebase: {
|
|
24
|
+
command: "git rebase --abort",
|
|
25
|
+
explanation: "This command cancels the active rebase operation and restores your branch.",
|
|
26
|
+
isDestructive: false,
|
|
27
|
+
},
|
|
28
|
+
merge: {
|
|
29
|
+
command: "git merge --abort",
|
|
30
|
+
explanation:
|
|
31
|
+
"This command attempts to abort the in-progress merge. " +
|
|
32
|
+
"Warning: if you had uncommitted changes before starting the merge (or edited conflicted files), some pre-merge changes may not be recoverable.",
|
|
33
|
+
isDestructive: true,
|
|
34
|
+
caution:
|
|
35
|
+
"\nCAUTION: `git merge --abort` discards in-progress merge conflict edits and cannot always recover uncommitted changes that existed before the merge started.",
|
|
36
|
+
},
|
|
37
|
+
"cherry-pick": {
|
|
38
|
+
command: "git cherry-pick --abort",
|
|
39
|
+
explanation: "This command cancels the active cherry-pick operation and restores your branch.",
|
|
40
|
+
isDestructive: false,
|
|
41
|
+
},
|
|
42
|
+
revert: {
|
|
43
|
+
command: "git revert --abort",
|
|
44
|
+
explanation: "This command cancels the active revert operation and restores your branch.",
|
|
45
|
+
isDestructive: false,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Determines a safe undo suggestion from repository state and the latest reflog entry.
|
|
51
|
+
* Always checks active Git operations (rebase, merge, cherry-pick, revert) first before
|
|
52
|
+
* inspecting historical reflog entries, and refuses to suggest destructive operations
|
|
53
|
+
* when the state is ambiguous.
|
|
54
|
+
*/
|
|
55
|
+
export function analyzeUndoState({
|
|
56
|
+
reflog,
|
|
57
|
+
repoStates = [],
|
|
58
|
+
rebaseInProgress = false,
|
|
59
|
+
origHeadExists = false,
|
|
60
|
+
} = {}) {
|
|
61
|
+
const activeStates = Array.isArray(repoStates) ? [...repoStates] : [];
|
|
62
|
+
if (rebaseInProgress && !activeStates.includes("rebase")) {
|
|
63
|
+
activeStates.push("rebase");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If multiple operations appear active simultaneously, state is ambiguous
|
|
67
|
+
if (activeStates.length > 1) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (activeStates.length === 1) {
|
|
72
|
+
const activeOp = activeStates[0];
|
|
73
|
+
return ACTIVE_OPERATION_SUGGESTIONS[activeOp] || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const firstLine =
|
|
77
|
+
String(reflog || "")
|
|
78
|
+
.split("\n")
|
|
79
|
+
.map((l) => l.trim())
|
|
80
|
+
.find(Boolean) || "";
|
|
81
|
+
|
|
82
|
+
const parsed = parseReflogAction(firstLine);
|
|
83
|
+
if (!parsed) return null;
|
|
84
|
+
|
|
85
|
+
const { action, detail } = parsed;
|
|
86
|
+
|
|
87
|
+
// Standard commit or amend (explicitly excluding `commit (initial)` which has no HEAD~1 parent)
|
|
88
|
+
if (action === "commit" || action === "commit (amend)") {
|
|
89
|
+
return {
|
|
90
|
+
command: "git reset --soft HEAD~1",
|
|
91
|
+
explanation: "This command undoes your last commit but keeps all your changes staged.",
|
|
92
|
+
isDestructive: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Completed merge or fast-forward pull: require both an explicit merge/pull action
|
|
97
|
+
// AND a verified ORIG_HEAD reference before suggesting a destructive hard reset.
|
|
98
|
+
const isMergeAction =
|
|
99
|
+
action === "merge" ||
|
|
100
|
+
action.startsWith("merge ") ||
|
|
101
|
+
action === "pull" ||
|
|
102
|
+
action.startsWith("pull ");
|
|
103
|
+
const isCompletedMergeDetail = /^(Fast-forward|Merge made by\b)/i.test(detail);
|
|
104
|
+
|
|
105
|
+
if (isMergeAction && isCompletedMergeDetail && origHeadExists) {
|
|
106
|
+
return {
|
|
107
|
+
command: "git reset --hard ORIG_HEAD",
|
|
108
|
+
explanation: "This command resets your branch to ORIG_HEAD (the state before the merge).",
|
|
109
|
+
isDestructive: true,
|
|
110
|
+
caution:
|
|
111
|
+
"\nCAUTION: This command is destructive (`git reset --hard`) and will discard uncommitted changes.",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
7
118
|
export async function undoCommand() {
|
|
8
119
|
const spinner = spin("Analyzing your recent Git history...");
|
|
9
120
|
try {
|
|
10
|
-
const reflog = await
|
|
121
|
+
const [reflog, repoStates, origHeadExists] = await Promise.all([
|
|
122
|
+
git.getReflog(),
|
|
123
|
+
git.getGitRepoState(),
|
|
124
|
+
git.hasOrigHead(),
|
|
125
|
+
]);
|
|
11
126
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
127
|
+
const suggestion = analyzeUndoState({
|
|
128
|
+
reflog,
|
|
129
|
+
repoStates,
|
|
130
|
+
origHeadExists,
|
|
131
|
+
});
|
|
15
132
|
|
|
16
|
-
if (!
|
|
17
|
-
spinner.warn("Couldn't determine a safe action to undo.");
|
|
133
|
+
if (!suggestion) {
|
|
134
|
+
spinner.warn("Couldn't determine a safe, unambiguous action to undo from recent Git state.");
|
|
18
135
|
return;
|
|
19
136
|
}
|
|
137
|
+
|
|
20
138
|
spinner.succeed();
|
|
21
139
|
|
|
22
|
-
const
|
|
140
|
+
const { command, explanation, isDestructive, caution } = suggestion;
|
|
141
|
+
const args = parseGitCommand(command);
|
|
23
142
|
|
|
143
|
+
if (isDestructive) {
|
|
144
|
+
console.log(
|
|
145
|
+
chalk.red.bold(
|
|
146
|
+
caution ||
|
|
147
|
+
"\nCAUTION: This command is destructive (`git reset --hard`) and will discard uncommitted changes."
|
|
148
|
+
)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
24
151
|
showBlock("Suggested Undo Command", command);
|
|
25
|
-
console.log(chalk.yellow(
|
|
152
|
+
console.log(chalk.yellow(explanation));
|
|
26
153
|
|
|
27
154
|
if (!(await confirm("Execute this undo command?"))) {
|
|
28
155
|
return aborted("Execution aborted by user.");
|
|
29
156
|
}
|
|
30
157
|
|
|
31
|
-
if (args
|
|
32
|
-
|
|
158
|
+
if (args.includes("--abort") && args[0] in ACTIVE_OPERATION_SUGGESTIONS) {
|
|
159
|
+
const currentStates = await git.getGitRepoState();
|
|
160
|
+
if (!currentStates.includes(args[0])) {
|
|
161
|
+
return console.log(chalk.yellow(`No ${args[0]} in progress — abort not necessary.`));
|
|
162
|
+
}
|
|
33
163
|
}
|
|
34
164
|
|
|
35
165
|
await runGit(args);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
|
|
6
|
+
export const CURRENT_CONFIG_VERSION = 2;
|
|
7
|
+
export const CURRENT_NOTICE_VERSION = 1;
|
|
8
|
+
export const DEFAULT_PROVIDER = "gemini";
|
|
9
|
+
export const DEFAULT_MODEL = "gemini-3.5-flash-lite";
|
|
10
|
+
const LEGACY_DEFAULT_MODEL = "gemini-2.5-flash";
|
|
11
|
+
|
|
12
|
+
const SAFE_MODEL_REGEX = /^[a-zA-Z0-9._-]+$/;
|
|
13
|
+
|
|
14
|
+
let customConfigDir = null;
|
|
15
|
+
|
|
16
|
+
export function setConfigDir(dir) {
|
|
17
|
+
customConfigDir = dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getConfigDir() {
|
|
21
|
+
if (customConfigDir) return customConfigDir;
|
|
22
|
+
if (process.env.GIT_PILOT_CONFIG_DIR) return process.env.GIT_PILOT_CONFIG_DIR;
|
|
23
|
+
|
|
24
|
+
if (process.platform === "win32") {
|
|
25
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
26
|
+
return path.join(appData, "git-pilot");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
30
|
+
return path.join(xdgConfig, "git-pilot");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getConfigPath() {
|
|
34
|
+
return path.join(getConfigDir(), "config.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getDefaultConfig() {
|
|
38
|
+
return {
|
|
39
|
+
version: CURRENT_CONFIG_VERSION,
|
|
40
|
+
provider: DEFAULT_PROVIDER,
|
|
41
|
+
model: DEFAULT_MODEL,
|
|
42
|
+
dataSharingNoticeAccepted: false,
|
|
43
|
+
dataSharingNoticeVersion: 0,
|
|
44
|
+
noticeAcceptedAt: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function hasAcceptedCurrentNotice(cfg) {
|
|
49
|
+
const effective = cfg || loadConfig();
|
|
50
|
+
return (
|
|
51
|
+
effective?.dataSharingNoticeAccepted === true &&
|
|
52
|
+
effective?.dataSharingNoticeVersion === CURRENT_NOTICE_VERSION
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function recordNoticeAcceptance() {
|
|
57
|
+
return saveConfig({
|
|
58
|
+
dataSharingNoticeAccepted: true,
|
|
59
|
+
dataSharingNoticeVersion: CURRENT_NOTICE_VERSION,
|
|
60
|
+
noticeAcceptedAt: new Date().toISOString(),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function validateConfig(data) {
|
|
65
|
+
if (!data || typeof data !== "object") {
|
|
66
|
+
throw new Error("Invalid configuration: root must be an object.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof data.version !== "number") {
|
|
70
|
+
throw new Error("Invalid configuration: missing or invalid 'version'.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (data.provider !== "gemini") {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Unsupported provider: "${data.provider}". Only "gemini" is supported in this release.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof data.model !== "string" || !SAFE_MODEL_REGEX.test(data.model)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Invalid model name "${data.model}". Model names can only contain alphanumeric characters, dots, underscores, and hyphens.`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function loadConfig() {
|
|
89
|
+
const configPath = getConfigPath();
|
|
90
|
+
if (!fs.existsSync(configPath)) {
|
|
91
|
+
return getDefaultConfig();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
|
|
98
|
+
const migrated = { ...getDefaultConfig(), ...parsed };
|
|
99
|
+
if (migrated.version < CURRENT_CONFIG_VERSION) {
|
|
100
|
+
// The old built-in model is restricted for many new API projects. Migrate
|
|
101
|
+
// only the previous default; preserve any user-selected model identifier.
|
|
102
|
+
if (migrated.model === LEGACY_DEFAULT_MODEL) {
|
|
103
|
+
migrated.model = DEFAULT_MODEL;
|
|
104
|
+
}
|
|
105
|
+
migrated.version = CURRENT_CONFIG_VERSION;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
validateConfig(migrated);
|
|
109
|
+
return migrated;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err.name === "SyntaxError") {
|
|
112
|
+
throw new Error(`Corrupted config file at ${configPath}: ${err.message}`, {
|
|
113
|
+
cause: err,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function saveConfig(patch = {}) {
|
|
121
|
+
const dir = getConfigDir();
|
|
122
|
+
const configPath = getConfigPath();
|
|
123
|
+
|
|
124
|
+
// Create directory with 0700 permissions on POSIX
|
|
125
|
+
if (!fs.existsSync(dir)) {
|
|
126
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
127
|
+
}
|
|
128
|
+
if (process.platform !== "win32") {
|
|
129
|
+
try {
|
|
130
|
+
fs.chmodSync(dir, 0o700);
|
|
131
|
+
} catch {
|
|
132
|
+
// Ignored on filesystems that do not support POSIX chmod
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let current = {};
|
|
137
|
+
if (fs.existsSync(configPath)) {
|
|
138
|
+
try {
|
|
139
|
+
current = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
140
|
+
if (current.version < CURRENT_CONFIG_VERSION) {
|
|
141
|
+
if (current.model === LEGACY_DEFAULT_MODEL) current.model = DEFAULT_MODEL;
|
|
142
|
+
current.version = CURRENT_CONFIG_VERSION;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Fall back to default config if file is unreadable/corrupted
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const updated = {
|
|
150
|
+
...getDefaultConfig(),
|
|
151
|
+
...current,
|
|
152
|
+
...patch,
|
|
153
|
+
version: CURRENT_CONFIG_VERSION,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
validateConfig(updated);
|
|
157
|
+
|
|
158
|
+
// Safe atomic write with 0600 permissions
|
|
159
|
+
const tempPath = path.join(dir, `.config.json.tmp.${process.pid}.${Date.now()}`);
|
|
160
|
+
const content = JSON.stringify(updated, null, 2) + "\n";
|
|
161
|
+
|
|
162
|
+
fs.writeFileSync(tempPath, content, { encoding: "utf8", mode: 0o600 });
|
|
163
|
+
if (process.platform !== "win32") {
|
|
164
|
+
try {
|
|
165
|
+
fs.chmodSync(tempPath, 0o600);
|
|
166
|
+
} catch {
|
|
167
|
+
// Ignored on filesystems that do not support POSIX chmod
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
fs.renameSync(tempPath, configPath);
|
|
172
|
+
return updated;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function resetConfig() {
|
|
176
|
+
const configPath = getConfigPath();
|
|
177
|
+
if (fs.existsSync(configPath)) {
|
|
178
|
+
fs.unlinkSync(configPath);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Redacts any key-like fields for printing or diagnostics */
|
|
183
|
+
export function redactConfig(cfg) {
|
|
184
|
+
const copy = { ...cfg };
|
|
185
|
+
for (const k of Object.keys(copy)) {
|
|
186
|
+
if (/key|secret|token|password/i.test(k)) {
|
|
187
|
+
copy[k] = "[REDACTED]";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return copy;
|
|
191
|
+
}
|