@oh-my-pi/pi-coding-agent 13.18.0 → 14.0.2
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/CHANGELOG.md +316 -1
- package/package.json +86 -24
- package/scripts/format-prompts.ts +2 -2
- package/src/autoresearch/apply-contract-to-state.ts +24 -0
- package/src/autoresearch/contract.ts +0 -44
- package/src/autoresearch/dashboard.ts +1 -2
- package/src/autoresearch/git.ts +116 -30
- package/src/autoresearch/helpers.ts +49 -0
- package/src/autoresearch/index.ts +28 -187
- package/src/autoresearch/prompt.md +26 -9
- package/src/autoresearch/state.ts +0 -6
- package/src/autoresearch/tools/init-experiment.ts +202 -117
- package/src/autoresearch/tools/log-experiment.ts +123 -178
- package/src/autoresearch/tools/run-experiment.ts +48 -10
- package/src/autoresearch/types.ts +2 -2
- package/src/capability/index.ts +4 -2
- package/src/cli/file-processor.ts +3 -3
- package/src/cli/grep-cli.ts +8 -8
- package/src/cli/grievances-cli.ts +78 -0
- package/src/cli/read-cli.ts +67 -0
- package/src/cli/setup-cli.ts +4 -4
- package/src/cli/update-cli.ts +3 -3
- package/src/cli.ts +2 -0
- package/src/commands/grep.ts +6 -1
- package/src/commands/grievances.ts +20 -0
- package/src/commands/read.ts +33 -0
- package/src/commit/agentic/agent.ts +5 -8
- package/src/commit/agentic/index.ts +22 -26
- package/src/commit/agentic/tools/analyze-file.ts +3 -3
- package/src/commit/agentic/tools/git-file-diff.ts +3 -6
- package/src/commit/agentic/tools/git-hunk.ts +3 -3
- package/src/commit/agentic/tools/git-overview.ts +6 -9
- package/src/commit/agentic/tools/index.ts +6 -8
- package/src/commit/agentic/tools/propose-commit.ts +4 -7
- package/src/commit/agentic/tools/recent-commits.ts +3 -3
- package/src/commit/agentic/tools/split-commit.ts +4 -4
- package/src/commit/agentic/validation.ts +1 -1
- package/src/commit/analysis/conventional.ts +4 -4
- package/src/commit/analysis/summary.ts +3 -3
- package/src/commit/changelog/generate.ts +4 -4
- package/src/commit/changelog/index.ts +5 -9
- package/src/commit/map-reduce/map-phase.ts +4 -4
- package/src/commit/map-reduce/reduce-phase.ts +4 -4
- package/src/commit/pipeline.ts +13 -16
- package/src/config/keybindings.ts +7 -6
- package/src/config/prompt-templates.ts +44 -226
- package/src/config/resolve-config-value.ts +4 -2
- package/src/config/settings-schema.ts +98 -2
- package/src/config/settings.ts +25 -26
- package/src/dap/client.ts +674 -0
- package/src/dap/config.ts +150 -0
- package/src/dap/defaults.json +211 -0
- package/src/dap/index.ts +4 -0
- package/src/dap/session.ts +1255 -0
- package/src/dap/types.ts +600 -0
- package/src/debug/log-viewer.ts +3 -2
- package/src/discovery/builtin.ts +1 -2
- package/src/discovery/codex.ts +2 -2
- package/src/discovery/github.ts +2 -1
- package/src/discovery/helpers.ts +2 -2
- package/src/discovery/opencode.ts +2 -2
- package/src/edit/diff.ts +818 -0
- package/src/edit/index.ts +309 -0
- package/src/edit/line-hash.ts +67 -0
- package/src/edit/modes/chunk.ts +454 -0
- package/src/{patch → edit/modes}/hashline.ts +741 -361
- package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
- package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
- package/src/{patch → edit}/normalize.ts +97 -76
- package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
- package/src/exec/bash-executor.ts +4 -2
- package/src/exec/idle-timeout-watchdog.ts +126 -0
- package/src/exec/non-interactive-env.ts +5 -0
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +6 -18
- package/src/extensibility/custom-commands/bundled/review/index.ts +45 -43
- package/src/extensibility/custom-commands/loader.ts +1 -2
- package/src/extensibility/custom-tools/loader.ts +34 -11
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/loader.ts +9 -4
- package/src/extensibility/extensions/runner.ts +24 -1
- package/src/extensibility/extensions/types.ts +4 -2
- package/src/extensibility/hooks/loader.ts +5 -6
- package/src/extensibility/hooks/types.ts +2 -2
- package/src/extensibility/plugins/doctor.ts +2 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
- package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
- package/src/extensibility/slash-commands.ts +3 -7
- package/src/index.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +11 -11
- package/src/ipy/executor.ts +58 -17
- package/src/ipy/gateway-coordinator.ts +6 -4
- package/src/ipy/kernel.ts +45 -22
- package/src/ipy/runtime.ts +2 -2
- package/src/lsp/client.ts +7 -4
- package/src/lsp/clients/lsp-linter-client.ts +4 -4
- package/src/lsp/config.ts +2 -2
- package/src/lsp/defaults.json +688 -154
- package/src/lsp/index.ts +234 -45
- package/src/lsp/lspmux.ts +2 -2
- package/src/lsp/startup-events.ts +13 -0
- package/src/lsp/types.ts +12 -1
- package/src/lsp/utils.ts +8 -1
- package/src/main.ts +125 -47
- package/src/memories/index.ts +4 -5
- package/src/modes/acp/acp-agent.ts +563 -163
- package/src/modes/acp/acp-event-mapper.ts +9 -1
- package/src/modes/acp/acp-mode.ts +4 -2
- package/src/modes/components/agent-dashboard.ts +3 -4
- package/src/modes/components/diff.ts +6 -7
- package/src/modes/components/footer.ts +9 -29
- package/src/modes/components/hook-editor.ts +3 -3
- package/src/modes/components/hook-selector.ts +6 -1
- package/src/modes/components/read-tool-group.ts +6 -12
- package/src/modes/components/session-observer-overlay.ts +472 -0
- package/src/modes/components/settings-defs.ts +24 -0
- package/src/modes/components/status-line.ts +15 -61
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/btw-controller.ts +2 -2
- package/src/modes/controllers/command-controller.ts +4 -2
- package/src/modes/controllers/event-controller.ts +59 -2
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +15 -8
- package/src/modes/controllers/selector-controller.ts +26 -0
- package/src/modes/index.ts +20 -2
- package/src/modes/interactive-mode.ts +278 -69
- package/src/modes/rpc/host-tools.ts +186 -0
- package/src/modes/rpc/rpc-client.ts +178 -13
- package/src/modes/rpc/rpc-mode.ts +73 -3
- package/src/modes/rpc/rpc-types.ts +53 -1
- package/src/modes/session-observer-registry.ts +146 -0
- package/src/modes/shared.ts +0 -42
- package/src/modes/theme/theme.ts +80 -8
- package/src/modes/types.ts +4 -2
- package/src/modes/utils/keybinding-matchers.ts +9 -0
- package/src/prompts/system/custom-system-prompt.md +5 -0
- package/src/prompts/system/system-prompt.md +8 -1
- package/src/prompts/tools/chunk-edit.md +219 -0
- package/src/prompts/tools/debug.md +43 -0
- package/src/prompts/tools/grep.md +3 -0
- package/src/prompts/tools/lsp.md +5 -5
- package/src/prompts/tools/read-chunk.md +17 -0
- package/src/prompts/tools/read.md +19 -5
- package/src/sdk.ts +216 -165
- package/src/secrets/index.ts +1 -1
- package/src/secrets/obfuscator.ts +25 -17
- package/src/session/agent-session.ts +381 -286
- package/src/session/agent-storage.ts +12 -12
- package/src/session/compaction/branch-summarization.ts +3 -3
- package/src/session/compaction/compaction.ts +5 -6
- package/src/session/compaction/utils.ts +3 -3
- package/src/session/history-storage.ts +62 -19
- package/src/session/messages.ts +3 -3
- package/src/session/session-dump-format.ts +203 -0
- package/src/session/session-manager.ts +15 -5
- package/src/session/session-storage.ts +4 -2
- package/src/session/streaming-output.ts +1 -1
- package/src/session/tool-choice-queue.ts +213 -0
- package/src/slash-commands/builtin-registry.ts +56 -8
- package/src/ssh/connection-manager.ts +2 -2
- package/src/ssh/sshfs-mount.ts +5 -5
- package/src/stt/downloader.ts +4 -4
- package/src/stt/recorder.ts +4 -4
- package/src/stt/transcriber.ts +2 -2
- package/src/system-prompt.ts +25 -13
- package/src/task/agents.ts +5 -6
- package/src/task/commands.ts +2 -5
- package/src/task/executor.ts +32 -4
- package/src/task/index.ts +91 -82
- package/src/task/template.ts +2 -2
- package/src/task/types.ts +25 -0
- package/src/task/worktree.ts +131 -149
- package/src/tools/ask.ts +2 -3
- package/src/tools/ast-edit.ts +7 -7
- package/src/tools/ast-grep.ts +7 -7
- package/src/tools/auto-generated-guard.ts +36 -41
- package/src/tools/await-tool.ts +2 -2
- package/src/tools/bash.ts +5 -23
- package/src/tools/browser.ts +4 -5
- package/src/tools/calculator.ts +2 -3
- package/src/tools/cancel-job.ts +2 -2
- package/src/tools/checkpoint.ts +3 -3
- package/src/tools/debug.ts +1007 -0
- package/src/tools/exit-plan-mode.ts +3 -3
- package/src/tools/fetch.ts +67 -3
- package/src/tools/find.ts +4 -5
- package/src/tools/fs-cache-invalidation.ts +5 -0
- package/src/tools/gemini-image.ts +13 -5
- package/src/tools/gh.ts +130 -308
- package/src/tools/grep.ts +57 -9
- package/src/tools/index.ts +44 -22
- package/src/tools/inspect-image.ts +4 -4
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/python.ts +19 -6
- package/src/tools/read.ts +211 -146
- package/src/tools/render-mermaid.ts +2 -3
- package/src/tools/render-utils.ts +20 -6
- package/src/tools/renderers.ts +3 -1
- package/src/tools/report-tool-issue.ts +80 -0
- package/src/tools/resolve.ts +70 -39
- package/src/tools/search-tool-bm25.ts +2 -2
- package/src/tools/ssh.ts +2 -2
- package/src/tools/todo-write.ts +2 -2
- package/src/tools/tool-timeouts.ts +1 -0
- package/src/tools/write.ts +5 -6
- package/src/tui/tree-list.ts +3 -1
- package/src/utils/clipboard.ts +80 -0
- package/src/utils/commit-message-generator.ts +2 -3
- package/src/utils/edit-mode.ts +49 -0
- package/src/utils/external-editor.ts +11 -5
- package/src/utils/file-display-mode.ts +6 -5
- package/src/utils/file-mentions.ts +8 -7
- package/src/utils/git.ts +1400 -0
- package/src/utils/image-loading.ts +98 -0
- package/src/utils/title-generator.ts +2 -3
- package/src/utils/tools-manager.ts +6 -6
- package/src/web/scrapers/choosealicense.ts +1 -1
- package/src/web/search/index.ts +3 -3
- package/src/web/search/render.ts +6 -4
- package/src/autoresearch/command-initialize.md +0 -34
- package/src/commit/git/errors.ts +0 -9
- package/src/commit/git/index.ts +0 -210
- package/src/commit/git/operations.ts +0 -54
- package/src/patch/diff.ts +0 -433
- package/src/patch/index.ts +0 -888
- package/src/patch/parser.ts +0 -532
- package/src/patch/types.ts +0 -292
- package/src/prompts/agents/oracle.md +0 -77
- package/src/tools/gh-cli.ts +0 -125
- package/src/tools/pending-action.ts +0 -49
- package/src/utils/child-process.ts +0 -88
- package/src/utils/frontmatter.ts +0 -117
- package/src/utils/image-input.ts +0 -274
- package/src/utils/mime.ts +0 -53
- package/src/utils/prompt-format.ts +0 -170
package/src/utils/git.ts
ADDED
|
@@ -0,0 +1,1400 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { $which, isEnoent, Snowflake } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import {
|
|
6
|
+
parseDiffHunks as parseCommitDiffHunks,
|
|
7
|
+
parseFileDiffs,
|
|
8
|
+
parseFileHunks,
|
|
9
|
+
parseNumstat,
|
|
10
|
+
} from "../commit/git/diff";
|
|
11
|
+
import type { FileDiff, FileHunks, NumstatEntry } from "../commit/types";
|
|
12
|
+
import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors";
|
|
13
|
+
|
|
14
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
15
|
+
// Types
|
|
16
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
17
|
+
|
|
18
|
+
export interface GitCommandResult {
|
|
19
|
+
exitCode: number;
|
|
20
|
+
stdout: string;
|
|
21
|
+
stderr: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GitRepository {
|
|
25
|
+
commonDir: string;
|
|
26
|
+
gitDir: string;
|
|
27
|
+
gitEntryPath: string;
|
|
28
|
+
headPath: string;
|
|
29
|
+
repoRoot: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GitStatusSummary {
|
|
33
|
+
staged: number;
|
|
34
|
+
unstaged: number;
|
|
35
|
+
untracked: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type HunkSelection = {
|
|
39
|
+
path: string;
|
|
40
|
+
hunks: { type: "all" } | { type: "indices"; indices: number[] } | { type: "lines"; start: number; end: number };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface DiffOptions {
|
|
44
|
+
readonly allowFailure?: boolean;
|
|
45
|
+
readonly base?: string;
|
|
46
|
+
readonly binary?: boolean;
|
|
47
|
+
readonly cached?: boolean;
|
|
48
|
+
readonly env?: Record<string, string | undefined>;
|
|
49
|
+
readonly files?: readonly string[];
|
|
50
|
+
readonly head?: string;
|
|
51
|
+
readonly nameOnly?: boolean;
|
|
52
|
+
readonly noIndex?: { left: string; right: string };
|
|
53
|
+
readonly numstat?: boolean;
|
|
54
|
+
readonly signal?: AbortSignal;
|
|
55
|
+
readonly stat?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface StatusOptions {
|
|
59
|
+
readonly pathspecs?: readonly string[];
|
|
60
|
+
readonly porcelainV1?: boolean;
|
|
61
|
+
readonly signal?: AbortSignal;
|
|
62
|
+
readonly untrackedFiles?: "all" | "no" | "normal";
|
|
63
|
+
readonly z?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CommitOptions {
|
|
67
|
+
readonly allowEmpty?: boolean;
|
|
68
|
+
readonly files?: readonly string[];
|
|
69
|
+
readonly signal?: AbortSignal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PushOptions {
|
|
73
|
+
readonly forceWithLease?: boolean;
|
|
74
|
+
readonly refspec?: string;
|
|
75
|
+
readonly remote?: string;
|
|
76
|
+
readonly signal?: AbortSignal;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface PatchOptions {
|
|
80
|
+
readonly cached?: boolean;
|
|
81
|
+
readonly check?: boolean;
|
|
82
|
+
readonly env?: Record<string, string | undefined>;
|
|
83
|
+
readonly signal?: AbortSignal;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RestoreOptions {
|
|
87
|
+
readonly files?: readonly string[];
|
|
88
|
+
readonly signal?: AbortSignal;
|
|
89
|
+
readonly source?: string;
|
|
90
|
+
readonly staged?: boolean;
|
|
91
|
+
readonly worktree?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CloneOptions {
|
|
95
|
+
readonly ref?: string;
|
|
96
|
+
readonly sha?: string;
|
|
97
|
+
readonly signal?: AbortSignal;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface GitHeadBase extends GitRepository {
|
|
101
|
+
headContent: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface GitRefHead extends GitHeadBase {
|
|
105
|
+
branchName: string | null;
|
|
106
|
+
commit: string | null;
|
|
107
|
+
kind: "ref";
|
|
108
|
+
ref: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface GitDetachedHead extends GitHeadBase {
|
|
112
|
+
commit: string | null;
|
|
113
|
+
kind: "detached";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type GitHeadState = GitRefHead | GitDetachedHead;
|
|
117
|
+
|
|
118
|
+
export interface GitWorktreeEntry {
|
|
119
|
+
branch?: string;
|
|
120
|
+
detached: boolean;
|
|
121
|
+
head?: string;
|
|
122
|
+
path: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
126
|
+
// Error
|
|
127
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
128
|
+
|
|
129
|
+
export class GitCommandError extends Error {
|
|
130
|
+
readonly args: readonly string[];
|
|
131
|
+
readonly result: GitCommandResult;
|
|
132
|
+
|
|
133
|
+
constructor(args: readonly string[], result: GitCommandResult) {
|
|
134
|
+
super(formatCommandFailure(args, result));
|
|
135
|
+
this.name = "GitCommandError";
|
|
136
|
+
this.args = [...args];
|
|
137
|
+
this.result = result;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
142
|
+
// Internal: Core execution
|
|
143
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
144
|
+
|
|
145
|
+
const NO_OPTIONAL_LOCKS = "--no-optional-locks";
|
|
146
|
+
const HEAD_REF_PREFIX = "ref:";
|
|
147
|
+
const LOCAL_BRANCH_PREFIX = "refs/heads/";
|
|
148
|
+
const DEFAULT_BRANCH_REFS = ["refs/remotes/origin/HEAD", "refs/remotes/upstream/HEAD"] as const;
|
|
149
|
+
|
|
150
|
+
interface CommandOptions {
|
|
151
|
+
readonly env?: Record<string, string | undefined>;
|
|
152
|
+
readonly readOnly?: boolean;
|
|
153
|
+
readonly signal?: AbortSignal;
|
|
154
|
+
readonly stdin?: string | Uint8Array | ArrayBuffer | SharedArrayBuffer;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeStdin(input: CommandOptions["stdin"]): "ignore" | Uint8Array {
|
|
158
|
+
if (input === undefined) return "ignore";
|
|
159
|
+
if (typeof input === "string") return new TextEncoder().encode(input);
|
|
160
|
+
if (input instanceof Uint8Array) return input;
|
|
161
|
+
return new Uint8Array(input);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function ensureAvailable(): void {
|
|
165
|
+
if (!$which("git")) {
|
|
166
|
+
throw new Error("git is not installed.");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function formatCommandFailure(
|
|
171
|
+
args: readonly string[],
|
|
172
|
+
result: Pick<GitCommandResult, "exitCode" | "stdout" | "stderr">,
|
|
173
|
+
): string {
|
|
174
|
+
const stderr = result.stderr.trim();
|
|
175
|
+
if (stderr) return stderr;
|
|
176
|
+
const stdout = result.stdout.trim();
|
|
177
|
+
if (stdout) return stdout;
|
|
178
|
+
return `git ${args.join(" ")} failed with exit code ${result.exitCode}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function runCommand(
|
|
182
|
+
cwd: string,
|
|
183
|
+
args: readonly string[],
|
|
184
|
+
options: CommandOptions = {},
|
|
185
|
+
): Promise<GitCommandResult> {
|
|
186
|
+
const commandArgs = options.readOnly ? withNoOptionalLocks(args) : [...args];
|
|
187
|
+
const child = Bun.spawn(["git", ...commandArgs], {
|
|
188
|
+
cwd,
|
|
189
|
+
env: options.env ? { ...process.env, ...options.env } : undefined,
|
|
190
|
+
signal: options.signal,
|
|
191
|
+
stdin: normalizeStdin(options.stdin),
|
|
192
|
+
stdout: "pipe",
|
|
193
|
+
stderr: "pipe",
|
|
194
|
+
windowsHide: true,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (!child.stdout || !child.stderr) {
|
|
198
|
+
throw new Error("Failed to capture git command output.");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
202
|
+
new Response(child.stdout).text(),
|
|
203
|
+
new Response(child.stderr).text(),
|
|
204
|
+
child.exited,
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
return { exitCode: exitCode ?? 0, stdout, stderr };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function withNoOptionalLocks(args: readonly string[]): string[] {
|
|
211
|
+
if (args.includes(NO_OPTIONAL_LOCKS)) return [...args];
|
|
212
|
+
return [NO_OPTIONAL_LOCKS, ...args];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function runChecked(
|
|
216
|
+
cwd: string,
|
|
217
|
+
args: readonly string[],
|
|
218
|
+
options: CommandOptions = {},
|
|
219
|
+
): Promise<GitCommandResult> {
|
|
220
|
+
ensureAvailable();
|
|
221
|
+
const result = await runCommand(cwd, args, options);
|
|
222
|
+
if (result.exitCode !== 0) {
|
|
223
|
+
throw new GitCommandError(args, result);
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function runEffect(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<void> {
|
|
229
|
+
await runChecked(cwd, args, options);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function runText(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<string> {
|
|
233
|
+
return (await runChecked(cwd, args, options)).stdout;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function tryText(
|
|
237
|
+
cwd: string,
|
|
238
|
+
args: readonly string[],
|
|
239
|
+
options: CommandOptions = {},
|
|
240
|
+
): Promise<string | undefined> {
|
|
241
|
+
ensureAvailable();
|
|
242
|
+
const result = await runCommand(cwd, args, options);
|
|
243
|
+
if (result.exitCode !== 0) return undefined;
|
|
244
|
+
return result.stdout;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function splitLines(text: string): string[] {
|
|
248
|
+
return text
|
|
249
|
+
.split("\n")
|
|
250
|
+
.map(line => line.trim())
|
|
251
|
+
.filter(Boolean);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function trimScalar(text: string | undefined): string | undefined {
|
|
255
|
+
const trimmed = text?.trim();
|
|
256
|
+
return trimmed || undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
260
|
+
// Internal: Argument builders
|
|
261
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
262
|
+
|
|
263
|
+
function buildDiffArgs(options: DiffOptions): string[] {
|
|
264
|
+
const args = ["diff"];
|
|
265
|
+
if (options.binary) args.push("--binary");
|
|
266
|
+
if (options.cached) args.push("--cached");
|
|
267
|
+
if (options.nameOnly) args.push("--name-only");
|
|
268
|
+
if (options.stat) args.push("--stat");
|
|
269
|
+
if (options.numstat) args.push("--numstat");
|
|
270
|
+
if (options.noIndex) {
|
|
271
|
+
args.push("--no-index", options.noIndex.left, options.noIndex.right);
|
|
272
|
+
return args;
|
|
273
|
+
}
|
|
274
|
+
if (options.base) {
|
|
275
|
+
args.push(options.base);
|
|
276
|
+
if (options.head) args.push(options.head);
|
|
277
|
+
}
|
|
278
|
+
if (options.files?.length) args.push("--", ...options.files);
|
|
279
|
+
return args;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function buildApplyArgs(patchPath: string, options: PatchOptions): string[] {
|
|
283
|
+
const args = ["apply"];
|
|
284
|
+
if (options.check) args.push("--check");
|
|
285
|
+
if (options.cached) args.push("--cached");
|
|
286
|
+
args.push("--binary", patchPath);
|
|
287
|
+
return args;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function writeTempPatch(content: string): Promise<string> {
|
|
291
|
+
const tempPath = path.join(os.tmpdir(), `omp-git-patch-${Snowflake.next()}.patch`);
|
|
292
|
+
await Bun.write(tempPath, content);
|
|
293
|
+
return tempPath;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
297
|
+
// Internal: Repository resolution
|
|
298
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
299
|
+
|
|
300
|
+
type EntryType = "directory" | "file";
|
|
301
|
+
|
|
302
|
+
function getEntryTypeSync(gitEntryPath: string): EntryType | null {
|
|
303
|
+
try {
|
|
304
|
+
const stat = fs.statSync(gitEntryPath);
|
|
305
|
+
if (stat.isDirectory()) return "directory";
|
|
306
|
+
if (stat.isFile()) return "file";
|
|
307
|
+
return null;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
if (isEnoent(err)) return null;
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function getEntryType(gitEntryPath: string): Promise<EntryType | null> {
|
|
315
|
+
try {
|
|
316
|
+
const stat = await fs.promises.stat(gitEntryPath);
|
|
317
|
+
if (stat.isDirectory()) return "directory";
|
|
318
|
+
if (stat.isFile()) return "file";
|
|
319
|
+
return null;
|
|
320
|
+
} catch (err) {
|
|
321
|
+
if (isEnoent(err)) return null;
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function readOptionalTextSync(filePath: string): string | null {
|
|
327
|
+
try {
|
|
328
|
+
return fs.readFileSync(filePath, "utf8");
|
|
329
|
+
} catch (err) {
|
|
330
|
+
if (isEnoent(err)) return null;
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function readOptionalText(filePath: string): Promise<string | null> {
|
|
336
|
+
try {
|
|
337
|
+
return await Bun.file(filePath).text();
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (isEnoent(err)) return null;
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function parseGitDirPointer(content: string): string | null {
|
|
345
|
+
const match = /^gitdir:\s*(.+)\s*$/iu.exec(content.trim());
|
|
346
|
+
return match?.[1] ?? null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function resolveGitDirSync(gitEntryPath: string, entryType: EntryType): string | null {
|
|
350
|
+
if (entryType === "directory") return gitEntryPath;
|
|
351
|
+
const content = readOptionalTextSync(gitEntryPath);
|
|
352
|
+
if (content === null) return null;
|
|
353
|
+
const parsed = parseGitDirPointer(content);
|
|
354
|
+
if (!parsed) return null;
|
|
355
|
+
const gitDir = path.resolve(path.dirname(gitEntryPath), parsed);
|
|
356
|
+
return getEntryTypeSync(gitDir) === "directory" ? gitDir : null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function resolveGitDir(gitEntryPath: string, entryType: EntryType): Promise<string | null> {
|
|
360
|
+
if (entryType === "directory") return gitEntryPath;
|
|
361
|
+
const content = await readOptionalText(gitEntryPath);
|
|
362
|
+
if (content === null) return null;
|
|
363
|
+
const parsed = parseGitDirPointer(content);
|
|
364
|
+
if (!parsed) return null;
|
|
365
|
+
const gitDir = path.resolve(path.dirname(gitEntryPath), parsed);
|
|
366
|
+
return (await getEntryType(gitDir)) === "directory" ? gitDir : null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function resolveCommonDirSync(gitDir: string): string {
|
|
370
|
+
const content = readOptionalTextSync(path.join(gitDir, "commondir"));
|
|
371
|
+
const relative = content?.trim();
|
|
372
|
+
if (!relative) return gitDir;
|
|
373
|
+
return path.resolve(gitDir, relative);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function resolveCommonDir(gitDir: string): Promise<string> {
|
|
377
|
+
const content = await readOptionalText(path.join(gitDir, "commondir"));
|
|
378
|
+
const relative = content?.trim();
|
|
379
|
+
if (!relative) return gitDir;
|
|
380
|
+
return path.resolve(gitDir, relative);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function resolveRepoFromEntrySync(repoRoot: string, gitEntryPath: string, entryType: EntryType): GitRepository | null {
|
|
384
|
+
const gitDir = resolveGitDirSync(gitEntryPath, entryType);
|
|
385
|
+
if (!gitDir) return null;
|
|
386
|
+
return {
|
|
387
|
+
commonDir: resolveCommonDirSync(gitDir),
|
|
388
|
+
gitDir,
|
|
389
|
+
gitEntryPath,
|
|
390
|
+
headPath: path.join(gitDir, "HEAD"),
|
|
391
|
+
repoRoot,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function resolveRepoFromEntry(
|
|
396
|
+
repoRoot: string,
|
|
397
|
+
gitEntryPath: string,
|
|
398
|
+
entryType: EntryType,
|
|
399
|
+
): Promise<GitRepository | null> {
|
|
400
|
+
const gitDir = await resolveGitDir(gitEntryPath, entryType);
|
|
401
|
+
if (!gitDir) return null;
|
|
402
|
+
return {
|
|
403
|
+
commonDir: await resolveCommonDir(gitDir),
|
|
404
|
+
gitDir,
|
|
405
|
+
gitEntryPath,
|
|
406
|
+
headPath: path.join(gitDir, "HEAD"),
|
|
407
|
+
repoRoot,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function resolveRepositorySync(startDir: string): GitRepository | null {
|
|
412
|
+
let current = path.resolve(startDir);
|
|
413
|
+
while (true) {
|
|
414
|
+
const gitEntryPath = path.join(current, ".git");
|
|
415
|
+
const entryType = getEntryTypeSync(gitEntryPath);
|
|
416
|
+
if (entryType) {
|
|
417
|
+
const repository = resolveRepoFromEntrySync(current, gitEntryPath, entryType);
|
|
418
|
+
if (repository) return repository;
|
|
419
|
+
}
|
|
420
|
+
const parent = path.dirname(current);
|
|
421
|
+
if (parent === current) return null;
|
|
422
|
+
current = parent;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function resolveRepository(startDir: string): Promise<GitRepository | null> {
|
|
427
|
+
let current = path.resolve(startDir);
|
|
428
|
+
while (true) {
|
|
429
|
+
const gitEntryPath = path.join(current, ".git");
|
|
430
|
+
const entryType = await getEntryType(gitEntryPath);
|
|
431
|
+
if (entryType) {
|
|
432
|
+
const repository = await resolveRepoFromEntry(current, gitEntryPath, entryType);
|
|
433
|
+
if (repository) return repository;
|
|
434
|
+
}
|
|
435
|
+
const parent = path.dirname(current);
|
|
436
|
+
if (parent === current) return null;
|
|
437
|
+
current = parent;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
442
|
+
// Internal: Ref resolution
|
|
443
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
444
|
+
|
|
445
|
+
function getRefLookupDirs(repository: GitRepository): string[] {
|
|
446
|
+
if (repository.gitDir === repository.commonDir) return [repository.gitDir];
|
|
447
|
+
return [repository.gitDir, repository.commonDir];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function normalizeRefValue(content: string | null): string | null {
|
|
451
|
+
const trimmed = content?.trim() ?? "";
|
|
452
|
+
return trimmed || null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function parsePackedRefs(content: string | null, targetRef: string): string | null {
|
|
456
|
+
if (!content) return null;
|
|
457
|
+
for (const line of content.split("\n")) {
|
|
458
|
+
const trimmed = line.trim();
|
|
459
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("^")) continue;
|
|
460
|
+
const [sha, refName] = trimmed.split(" ", 2);
|
|
461
|
+
if (refName === targetRef && sha) return sha;
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function readRefSync(repository: GitRepository, targetRef: string): string | null {
|
|
467
|
+
for (const dir of getRefLookupDirs(repository)) {
|
|
468
|
+
const value = normalizeRefValue(readOptionalTextSync(path.join(dir, targetRef)));
|
|
469
|
+
if (value) return value;
|
|
470
|
+
}
|
|
471
|
+
for (const dir of getRefLookupDirs(repository)) {
|
|
472
|
+
const value = parsePackedRefs(readOptionalTextSync(path.join(dir, "packed-refs")), targetRef);
|
|
473
|
+
if (value) return value;
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function readRef(repository: GitRepository, targetRef: string): Promise<string | null> {
|
|
479
|
+
for (const dir of getRefLookupDirs(repository)) {
|
|
480
|
+
const value = normalizeRefValue(await readOptionalText(path.join(dir, targetRef)));
|
|
481
|
+
if (value) return value;
|
|
482
|
+
}
|
|
483
|
+
for (const dir of getRefLookupDirs(repository)) {
|
|
484
|
+
const value = parsePackedRefs(await readOptionalText(path.join(dir, "packed-refs")), targetRef);
|
|
485
|
+
if (value) return value;
|
|
486
|
+
}
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
491
|
+
// Internal: Head state parsing
|
|
492
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
493
|
+
|
|
494
|
+
function parseHeadStateSync(repository: GitRepository, headContent: string): GitHeadState {
|
|
495
|
+
const trimmed = headContent.trim();
|
|
496
|
+
if (!trimmed?.startsWith(HEAD_REF_PREFIX)) {
|
|
497
|
+
return { ...repository, commit: trimmed || null, headContent, kind: "detached" };
|
|
498
|
+
}
|
|
499
|
+
const refValue = trimmed.slice(HEAD_REF_PREFIX.length).trim();
|
|
500
|
+
const branchName = refValue.startsWith(LOCAL_BRANCH_PREFIX) ? refValue.slice(LOCAL_BRANCH_PREFIX.length) : null;
|
|
501
|
+
return {
|
|
502
|
+
...repository,
|
|
503
|
+
branchName,
|
|
504
|
+
commit: readRefSync(repository, refValue),
|
|
505
|
+
headContent,
|
|
506
|
+
kind: "ref",
|
|
507
|
+
ref: refValue,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function parseHeadState(repository: GitRepository, headContent: string): Promise<GitHeadState> {
|
|
512
|
+
const trimmed = headContent.trim();
|
|
513
|
+
if (!trimmed?.startsWith(HEAD_REF_PREFIX)) {
|
|
514
|
+
return { ...repository, commit: trimmed || null, headContent, kind: "detached" };
|
|
515
|
+
}
|
|
516
|
+
const refValue = trimmed.slice(HEAD_REF_PREFIX.length).trim();
|
|
517
|
+
const branchName = refValue.startsWith(LOCAL_BRANCH_PREFIX) ? refValue.slice(LOCAL_BRANCH_PREFIX.length) : null;
|
|
518
|
+
return {
|
|
519
|
+
...repository,
|
|
520
|
+
branchName,
|
|
521
|
+
commit: await readRef(repository, refValue),
|
|
522
|
+
headContent,
|
|
523
|
+
kind: "ref",
|
|
524
|
+
ref: refValue,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function parseDefaultBranchRef(refPath: string, target: string | null): string | null {
|
|
529
|
+
if (!target?.startsWith(HEAD_REF_PREFIX)) return null;
|
|
530
|
+
const resolvedRef = target.slice(HEAD_REF_PREFIX.length).trim();
|
|
531
|
+
const remotePrefix = refPath.slice(0, -"HEAD".length);
|
|
532
|
+
if (!resolvedRef.startsWith(remotePrefix)) return null;
|
|
533
|
+
return resolvedRef.slice(remotePrefix.length) || null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function stripRemotePrefix(refValue: string): string | null {
|
|
537
|
+
const slash = refValue.indexOf("/");
|
|
538
|
+
if (slash < 0) return refValue || null;
|
|
539
|
+
return refValue.slice(slash + 1) || null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function parseWorktreeList(text: string): GitWorktreeEntry[] {
|
|
543
|
+
const trimmed = text.trim();
|
|
544
|
+
if (!trimmed) return [];
|
|
545
|
+
return trimmed
|
|
546
|
+
.split(/\n\s*\n/)
|
|
547
|
+
.map(block => block.trim())
|
|
548
|
+
.filter(Boolean)
|
|
549
|
+
.map(block => {
|
|
550
|
+
const entry: GitWorktreeEntry = { detached: false, path: "" };
|
|
551
|
+
for (const line of block.split("\n")) {
|
|
552
|
+
if (line.startsWith("worktree ")) entry.path = line.slice("worktree ".length);
|
|
553
|
+
else if (line.startsWith("HEAD ")) entry.head = line.slice("HEAD ".length);
|
|
554
|
+
else if (line.startsWith("branch ")) entry.branch = line.slice("branch ".length);
|
|
555
|
+
else if (line === "detached") entry.detached = true;
|
|
556
|
+
}
|
|
557
|
+
return entry;
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
562
|
+
// Internal: Hunk selection
|
|
563
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
564
|
+
|
|
565
|
+
function extractFileHeader(diffText: string): string {
|
|
566
|
+
const lines = diffText.split("\n");
|
|
567
|
+
const headerLines: string[] = [];
|
|
568
|
+
for (const line of lines) {
|
|
569
|
+
if (line.startsWith("@@")) break;
|
|
570
|
+
headerLines.push(line);
|
|
571
|
+
}
|
|
572
|
+
return headerLines.join("\n");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function selectHunks(file: FileHunks, selector: HunkSelection["hunks"]): FileHunks["hunks"] {
|
|
576
|
+
if (selector.type === "indices") {
|
|
577
|
+
const wanted = new Set(selector.indices.map(v => Math.max(1, Math.floor(v))));
|
|
578
|
+
return file.hunks.filter(hunk => wanted.has(hunk.index + 1));
|
|
579
|
+
}
|
|
580
|
+
if (selector.type === "lines") {
|
|
581
|
+
const start = Math.floor(selector.start);
|
|
582
|
+
const end = Math.floor(selector.end);
|
|
583
|
+
return file.hunks.filter(hunk => hunk.newStart <= end && hunk.newStart + hunk.newLines - 1 >= start);
|
|
584
|
+
}
|
|
585
|
+
return file.hunks;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function parseStatusPorcelain(text: string): GitStatusSummary {
|
|
589
|
+
let staged = 0;
|
|
590
|
+
let unstaged = 0;
|
|
591
|
+
let untracked = 0;
|
|
592
|
+
for (const line of text.split("\n")) {
|
|
593
|
+
if (!line) continue;
|
|
594
|
+
const x = line[0];
|
|
595
|
+
const y = line[1];
|
|
596
|
+
if (x === "?" && y === "?") {
|
|
597
|
+
untracked += 1;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (x && x !== " " && x !== "?") staged += 1;
|
|
601
|
+
if (y && y !== " ") unstaged += 1;
|
|
602
|
+
}
|
|
603
|
+
return { staged, unstaged, untracked };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
607
|
+
// API: diff
|
|
608
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
609
|
+
|
|
610
|
+
/** Run `git diff` with the given options. Returns raw diff text. */
|
|
611
|
+
export const diff = Object.assign(
|
|
612
|
+
async function diff(cwd: string, options: DiffOptions = {}): Promise<string> {
|
|
613
|
+
const args = buildDiffArgs(options);
|
|
614
|
+
if (options.allowFailure) {
|
|
615
|
+
return (await runCommand(cwd, args, { env: options.env, readOnly: true, signal: options.signal })).stdout;
|
|
616
|
+
}
|
|
617
|
+
return runText(cwd, args, { env: options.env, readOnly: true, signal: options.signal });
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
/** List changed file paths. */
|
|
621
|
+
async changedFiles(
|
|
622
|
+
cwd: string,
|
|
623
|
+
options: Pick<DiffOptions, "cached" | "files" | "signal"> = {},
|
|
624
|
+
): Promise<string[]> {
|
|
625
|
+
return splitLines(await diff(cwd, { ...options, nameOnly: true }));
|
|
626
|
+
},
|
|
627
|
+
/** Parsed per-file add/remove counts. */
|
|
628
|
+
async numstat(cwd: string, options: Pick<DiffOptions, "cached" | "signal"> = {}): Promise<NumstatEntry[]> {
|
|
629
|
+
return parseNumstat(await diff(cwd, { ...options, numstat: true }));
|
|
630
|
+
},
|
|
631
|
+
/** Parsed diff hunks for the given files. */
|
|
632
|
+
async hunks(
|
|
633
|
+
cwd: string,
|
|
634
|
+
files: readonly string[],
|
|
635
|
+
options: { cached?: boolean; signal?: AbortSignal } = {},
|
|
636
|
+
): Promise<FileHunks[]> {
|
|
637
|
+
return parseCommitDiffHunks(
|
|
638
|
+
await diff(cwd, { cached: options.cached ?? true, files, signal: options.signal }),
|
|
639
|
+
);
|
|
640
|
+
},
|
|
641
|
+
/** Check whether a diff exists (uses `--quiet` for efficiency). */
|
|
642
|
+
async has(cwd: string, options: Pick<DiffOptions, "cached" | "files" | "signal"> = {}): Promise<boolean> {
|
|
643
|
+
const args = ["diff"];
|
|
644
|
+
if (options.cached) args.push("--cached");
|
|
645
|
+
args.push("--quiet");
|
|
646
|
+
if (options.files?.length) args.push("--", ...options.files);
|
|
647
|
+
const result = await runCommand(cwd, args, { readOnly: true, signal: options.signal });
|
|
648
|
+
if (result.exitCode === 0) return false;
|
|
649
|
+
if (result.exitCode === 1) return true;
|
|
650
|
+
throw new GitCommandError(args, result);
|
|
651
|
+
},
|
|
652
|
+
/** Diff between two tree-ish objects (`git diff-tree`). */
|
|
653
|
+
async tree(
|
|
654
|
+
cwd: string,
|
|
655
|
+
base: string,
|
|
656
|
+
headRef: string,
|
|
657
|
+
options: { binary?: boolean; signal?: AbortSignal; allowFailure?: boolean } = {},
|
|
658
|
+
): Promise<string> {
|
|
659
|
+
const args = ["diff-tree", "-r", "-p"];
|
|
660
|
+
if (options.binary) args.push("--binary");
|
|
661
|
+
args.push(base, headRef);
|
|
662
|
+
if (options.allowFailure) {
|
|
663
|
+
return (await runCommand(cwd, args, { readOnly: true, signal: options.signal })).stdout;
|
|
664
|
+
}
|
|
665
|
+
return runText(cwd, args, { readOnly: true, signal: options.signal });
|
|
666
|
+
},
|
|
667
|
+
/** Parse raw diff text into per-file diffs. */
|
|
668
|
+
parseFiles(text: string): FileDiff[] {
|
|
669
|
+
return parseFileDiffs(text);
|
|
670
|
+
},
|
|
671
|
+
/** Parse raw diff text into per-file hunks. */
|
|
672
|
+
parseHunks(text: string): FileHunks[] {
|
|
673
|
+
return parseCommitDiffHunks(text);
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
679
|
+
// API: status
|
|
680
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
681
|
+
|
|
682
|
+
/** Run `git status --porcelain`. Returns raw status text. */
|
|
683
|
+
export const status = Object.assign(
|
|
684
|
+
async function status(cwd: string, options: StatusOptions = {}): Promise<string> {
|
|
685
|
+
const args = ["status"];
|
|
686
|
+
args.push(options.porcelainV1 ? "--porcelain=v1" : "--porcelain");
|
|
687
|
+
if (options.z) args.push("-z");
|
|
688
|
+
if (options.untrackedFiles) args.push(`--untracked-files=${options.untrackedFiles}`);
|
|
689
|
+
if (options.pathspecs?.length) args.push("--", ...options.pathspecs);
|
|
690
|
+
return runText(cwd, args, { readOnly: true, signal: options.signal });
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
/** Parsed status counts (staged, unstaged, untracked). */
|
|
694
|
+
async summary(cwd: string, signal?: AbortSignal): Promise<GitStatusSummary | null> {
|
|
695
|
+
const result = await runCommand(cwd, ["status", "--porcelain"], { readOnly: true, signal });
|
|
696
|
+
if (result.exitCode !== 0) return null;
|
|
697
|
+
return parseStatusPorcelain(result.stdout);
|
|
698
|
+
},
|
|
699
|
+
/** Parse porcelain status text into counts. */
|
|
700
|
+
parse: parseStatusPorcelain,
|
|
701
|
+
},
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
705
|
+
// API: stage
|
|
706
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
707
|
+
|
|
708
|
+
export const stage = {
|
|
709
|
+
/** Stage files. Empty array stages all (`git add -A`). */
|
|
710
|
+
async files(cwd: string, files: readonly string[] = [], signal?: AbortSignal): Promise<void> {
|
|
711
|
+
const args = files.length === 0 ? ["add", "-A"] : ["add", "--", ...files];
|
|
712
|
+
await runEffect(cwd, args, { signal });
|
|
713
|
+
},
|
|
714
|
+
|
|
715
|
+
/** Selectively stage hunks from the working tree diff. */
|
|
716
|
+
async hunks(cwd: string, selections: HunkSelection[], signal?: AbortSignal): Promise<void> {
|
|
717
|
+
if (selections.length === 0) return;
|
|
718
|
+
const rawDiff = await diff(cwd, { cached: false, signal });
|
|
719
|
+
const fileDiffs = parseFileDiffs(rawDiff);
|
|
720
|
+
const fileDiffMap = new Map(fileDiffs.map(entry => [entry.filename, entry]));
|
|
721
|
+
const patchParts: string[] = [];
|
|
722
|
+
|
|
723
|
+
for (const selection of selections) {
|
|
724
|
+
const fileDiff = fileDiffMap.get(selection.path);
|
|
725
|
+
if (!fileDiff) throw new Error(`No diff found for ${selection.path}`);
|
|
726
|
+
if (fileDiff.isBinary) {
|
|
727
|
+
if (selection.hunks.type !== "all")
|
|
728
|
+
throw new Error(`Cannot select hunks for binary file ${selection.path}`);
|
|
729
|
+
patchParts.push(fileDiff.content);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (selection.hunks.type === "all") {
|
|
733
|
+
patchParts.push(fileDiff.content);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
const fileHunks = parseFileHunks(fileDiff);
|
|
737
|
+
const selected = selectHunks(fileHunks, selection.hunks);
|
|
738
|
+
if (selected.length === 0) throw new Error(`No hunks selected for ${selection.path}`);
|
|
739
|
+
const header = extractFileHeader(fileDiff.content);
|
|
740
|
+
patchParts.push([header, ...selected.map(h => h.content)].join("\n"));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const patchText = patch.join(patchParts);
|
|
744
|
+
if (!patchText.trim()) return;
|
|
745
|
+
await patch.applyText(cwd, patchText, { cached: true, signal });
|
|
746
|
+
},
|
|
747
|
+
|
|
748
|
+
/** Unstage files. Empty array unstages all (`git reset`). */
|
|
749
|
+
async reset(cwd: string, files: readonly string[] = [], signal?: AbortSignal): Promise<void> {
|
|
750
|
+
const args = files.length === 0 ? ["reset"] : ["reset", "--", ...files];
|
|
751
|
+
await runEffect(cwd, args, { signal });
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
756
|
+
// API: commit, push, checkout
|
|
757
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
758
|
+
|
|
759
|
+
/** Create a commit with the given message (passed via stdin). */
|
|
760
|
+
export async function commit(cwd: string, message: string, options: CommitOptions = {}): Promise<GitCommandResult> {
|
|
761
|
+
const args = ["commit", "-F", "-"];
|
|
762
|
+
if (options.allowEmpty) args.push("--allow-empty");
|
|
763
|
+
if (options.files?.length) args.push("--", ...options.files);
|
|
764
|
+
return runChecked(cwd, args, { signal: options.signal, stdin: message });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Push the current branch. */
|
|
768
|
+
export async function push(cwd: string, options: PushOptions = {}): Promise<void> {
|
|
769
|
+
const args = ["push"];
|
|
770
|
+
if (options.forceWithLease) args.push("--force-with-lease");
|
|
771
|
+
if (options.remote) args.push(options.remote);
|
|
772
|
+
if (options.refspec) args.push(options.refspec);
|
|
773
|
+
await runEffect(cwd, args, { signal: options.signal });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Checkout a ref. */
|
|
777
|
+
export async function checkout(cwd: string, ref: string, signal?: AbortSignal): Promise<void> {
|
|
778
|
+
await runEffect(cwd, ["checkout", ref], { signal });
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Fetch a specific refspec from a remote. */
|
|
782
|
+
export async function fetch(
|
|
783
|
+
cwd: string,
|
|
784
|
+
remote: string,
|
|
785
|
+
source: string,
|
|
786
|
+
target: string,
|
|
787
|
+
signal?: AbortSignal,
|
|
788
|
+
): Promise<void> {
|
|
789
|
+
await runEffect(cwd, ["fetch", remote, `+${source}:${target}`], { signal });
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Read a tree-ish into the index. */
|
|
793
|
+
export async function readTree(
|
|
794
|
+
cwd: string,
|
|
795
|
+
treeish: string,
|
|
796
|
+
options: Pick<CommandOptions, "env" | "signal"> = {},
|
|
797
|
+
): Promise<void> {
|
|
798
|
+
await runEffect(cwd, ["read-tree", treeish], options);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
802
|
+
// API: show
|
|
803
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
804
|
+
|
|
805
|
+
/** Run `git show` on a revision. */
|
|
806
|
+
export const show = Object.assign(
|
|
807
|
+
async function show(
|
|
808
|
+
cwd: string,
|
|
809
|
+
revision: string,
|
|
810
|
+
options: { format?: string; signal?: AbortSignal } = {},
|
|
811
|
+
): Promise<string> {
|
|
812
|
+
return runText(cwd, ["show", `--format=${options.format ?? ""}`, revision], {
|
|
813
|
+
readOnly: true,
|
|
814
|
+
signal: options.signal,
|
|
815
|
+
});
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
/** Get the path prefix of the current directory relative to the repo root. */
|
|
819
|
+
async prefix(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
820
|
+
return (await runText(cwd, ["rev-parse", "--show-prefix"], { readOnly: true, signal })).trim();
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
826
|
+
// API: log
|
|
827
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
828
|
+
|
|
829
|
+
export const log = {
|
|
830
|
+
/** Recent commit subjects (one-line each). */
|
|
831
|
+
async subjects(cwd: string, count: number, signal?: AbortSignal): Promise<string[]> {
|
|
832
|
+
return splitLines(await runText(cwd, ["log", `-n${count}`, "--pretty=format:%s"], { readOnly: true, signal }));
|
|
833
|
+
},
|
|
834
|
+
/** Recent commits as `<short-sha> <subject>` onelines. */
|
|
835
|
+
async onelines(cwd: string, count: number, signal?: AbortSignal): Promise<string[]> {
|
|
836
|
+
return splitLines(
|
|
837
|
+
await runText(cwd, ["log", `-${count}`, "--oneline", "--no-decorate"], { readOnly: true, signal }),
|
|
838
|
+
);
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
843
|
+
// API: branch
|
|
844
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
845
|
+
|
|
846
|
+
export const branch = {
|
|
847
|
+
/** Current branch name, or null if detached/unavailable. */
|
|
848
|
+
async current(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
849
|
+
const headState = await resolveHead(cwd);
|
|
850
|
+
if (headState?.kind === "ref") return headState.branchName ?? headState.ref;
|
|
851
|
+
const result = await runCommand(cwd, ["symbolic-ref", "--short", "HEAD"], { readOnly: true, signal });
|
|
852
|
+
if (result.exitCode !== 0) return null;
|
|
853
|
+
return result.stdout.trim() || null;
|
|
854
|
+
},
|
|
855
|
+
|
|
856
|
+
/** Default branch name (from remote HEAD refs). */
|
|
857
|
+
async default(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
858
|
+
const repository = await resolveRepository(cwd);
|
|
859
|
+
if (repository) {
|
|
860
|
+
for (const refPath of DEFAULT_BRANCH_REFS) {
|
|
861
|
+
const target = await readRef(repository, refPath);
|
|
862
|
+
const branchName = parseDefaultBranchRef(refPath, target);
|
|
863
|
+
if (branchName) return branchName;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
for (const remoteRef of ["origin/HEAD", "upstream/HEAD"]) {
|
|
867
|
+
const result = await runCommand(cwd, ["rev-parse", "--abbrev-ref", remoteRef], { readOnly: true, signal });
|
|
868
|
+
if (result.exitCode !== 0) continue;
|
|
869
|
+
const branchName = stripRemotePrefix(result.stdout.trim());
|
|
870
|
+
if (branchName) return branchName;
|
|
871
|
+
}
|
|
872
|
+
return null;
|
|
873
|
+
},
|
|
874
|
+
|
|
875
|
+
/** Create a new branch at the given start point. */
|
|
876
|
+
async create(cwd: string, name: string, startPoint = "HEAD", signal?: AbortSignal): Promise<void> {
|
|
877
|
+
await runEffect(cwd, ["branch", name, startPoint], { signal });
|
|
878
|
+
},
|
|
879
|
+
|
|
880
|
+
/** Force-move a branch to a new start point. */
|
|
881
|
+
async force(cwd: string, name: string, startPoint: string, signal?: AbortSignal): Promise<void> {
|
|
882
|
+
await runEffect(cwd, ["branch", "--force", name, startPoint], { signal });
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
/** Delete a branch. Throws on failure. */
|
|
886
|
+
async delete(cwd: string, name: string, options: { force?: boolean; signal?: AbortSignal } = {}): Promise<void> {
|
|
887
|
+
await runEffect(cwd, ["branch", options.force === false ? "-d" : "-D", name], { signal: options.signal });
|
|
888
|
+
},
|
|
889
|
+
|
|
890
|
+
/** Delete a branch. Returns false on failure instead of throwing. */
|
|
891
|
+
async tryDelete(
|
|
892
|
+
cwd: string,
|
|
893
|
+
name: string,
|
|
894
|
+
options: { force?: boolean; signal?: AbortSignal } = {},
|
|
895
|
+
): Promise<boolean> {
|
|
896
|
+
const result = await runCommand(cwd, ["branch", options.force === false ? "-d" : "-D", name], {
|
|
897
|
+
signal: options.signal,
|
|
898
|
+
});
|
|
899
|
+
return result.exitCode === 0;
|
|
900
|
+
},
|
|
901
|
+
|
|
902
|
+
/** Create and checkout a new branch. */
|
|
903
|
+
async checkoutNew(cwd: string, name: string, signal?: AbortSignal): Promise<void> {
|
|
904
|
+
await runEffect(cwd, ["checkout", "-b", name], { signal });
|
|
905
|
+
},
|
|
906
|
+
|
|
907
|
+
/** List branches. Pass `{ all: true }` to include remotes. */
|
|
908
|
+
async list(cwd: string, options: { all?: boolean; signal?: AbortSignal } = {}): Promise<string[]> {
|
|
909
|
+
const args = ["branch"];
|
|
910
|
+
if (options.all) args.push("-a");
|
|
911
|
+
args.push("--format=%(refname:short)");
|
|
912
|
+
return splitLines(await runText(cwd, args, { readOnly: true, signal: options.signal }));
|
|
913
|
+
},
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
917
|
+
// API: remote
|
|
918
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
919
|
+
|
|
920
|
+
export const remote = {
|
|
921
|
+
/** List remote names. */
|
|
922
|
+
async list(cwd: string, signal?: AbortSignal): Promise<string[]> {
|
|
923
|
+
return splitLines(await runText(cwd, ["remote"], { readOnly: true, signal }));
|
|
924
|
+
},
|
|
925
|
+
|
|
926
|
+
/** Get the URL for a remote. */
|
|
927
|
+
async url(cwd: string, name: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
928
|
+
return trimScalar(await tryText(cwd, ["remote", "get-url", name], { readOnly: true, signal }));
|
|
929
|
+
},
|
|
930
|
+
|
|
931
|
+
/** Add a new remote. */
|
|
932
|
+
async add(cwd: string, name: string, url: string, signal?: AbortSignal): Promise<void> {
|
|
933
|
+
await runEffect(cwd, ["remote", "add", name, url], { signal });
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
938
|
+
// API: ref
|
|
939
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
940
|
+
|
|
941
|
+
export const ref = {
|
|
942
|
+
/** Check if a ref exists. */
|
|
943
|
+
async exists(cwd: string, refName: string, signal?: AbortSignal): Promise<boolean> {
|
|
944
|
+
if (refName === "HEAD") return (await head.sha(cwd, signal)) !== null;
|
|
945
|
+
const repository = await resolveRepository(cwd);
|
|
946
|
+
if (repository && refName.startsWith("refs/")) return (await readRef(repository, refName)) !== null;
|
|
947
|
+
const result = await runCommand(cwd, ["show-ref", "--verify", "--quiet", refName], { readOnly: true, signal });
|
|
948
|
+
return result.exitCode === 0;
|
|
949
|
+
},
|
|
950
|
+
|
|
951
|
+
/** Resolve a ref to its commit SHA. */
|
|
952
|
+
async resolve(cwd: string, refName: string, signal?: AbortSignal): Promise<string | null> {
|
|
953
|
+
if (refName === "HEAD") return head.sha(cwd, signal);
|
|
954
|
+
const repository = await resolveRepository(cwd);
|
|
955
|
+
if (repository && refName.startsWith("refs/")) return readRef(repository, refName);
|
|
956
|
+
const result = await runCommand(cwd, ["rev-parse", refName], { readOnly: true, signal });
|
|
957
|
+
if (result.exitCode !== 0) return null;
|
|
958
|
+
return result.stdout.trim() || null;
|
|
959
|
+
},
|
|
960
|
+
|
|
961
|
+
/** Tags pointing at a ref. */
|
|
962
|
+
async tags(cwd: string, refName = "HEAD", signal?: AbortSignal): Promise<string[]> {
|
|
963
|
+
return splitLines(
|
|
964
|
+
await runText(
|
|
965
|
+
cwd,
|
|
966
|
+
[
|
|
967
|
+
"for-each-ref",
|
|
968
|
+
"--points-at",
|
|
969
|
+
refName,
|
|
970
|
+
"--sort=-version:refname",
|
|
971
|
+
"--format=%(refname:strip=2)",
|
|
972
|
+
"refs/tags",
|
|
973
|
+
],
|
|
974
|
+
{ readOnly: true, signal },
|
|
975
|
+
),
|
|
976
|
+
);
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
981
|
+
// API: config
|
|
982
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
983
|
+
|
|
984
|
+
export const config = {
|
|
985
|
+
async get(cwd: string, key: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
986
|
+
return trimScalar(await tryText(cwd, ["config", "--get", key], { readOnly: true, signal }));
|
|
987
|
+
},
|
|
988
|
+
|
|
989
|
+
async set(cwd: string, key: string, value: string, signal?: AbortSignal): Promise<void> {
|
|
990
|
+
await runEffect(cwd, ["config", key, value], { signal });
|
|
991
|
+
},
|
|
992
|
+
|
|
993
|
+
async getBranch(cwd: string, branchName: string, key: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
994
|
+
return config.get(cwd, `branch.${branchName}.${key}`, signal);
|
|
995
|
+
},
|
|
996
|
+
|
|
997
|
+
async setBranch(cwd: string, branchName: string, key: string, value: string, signal?: AbortSignal): Promise<void> {
|
|
998
|
+
return config.set(cwd, `branch.${branchName}.${key}`, value, signal);
|
|
999
|
+
},
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1003
|
+
// API: worktree
|
|
1004
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1005
|
+
|
|
1006
|
+
export const worktree = {
|
|
1007
|
+
async add(
|
|
1008
|
+
cwd: string,
|
|
1009
|
+
worktreePath: string,
|
|
1010
|
+
refName: string,
|
|
1011
|
+
options: { detach?: boolean; signal?: AbortSignal } = {},
|
|
1012
|
+
): Promise<void> {
|
|
1013
|
+
const args = ["worktree", "add"];
|
|
1014
|
+
if (options.detach) args.push("--detach");
|
|
1015
|
+
args.push(worktreePath, refName);
|
|
1016
|
+
await runEffect(cwd, args, { signal: options.signal });
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
async remove(
|
|
1020
|
+
cwd: string,
|
|
1021
|
+
worktreePath: string,
|
|
1022
|
+
options: { force?: boolean; signal?: AbortSignal } = {},
|
|
1023
|
+
): Promise<void> {
|
|
1024
|
+
const args = ["worktree", "remove"];
|
|
1025
|
+
if (options.force ?? true) args.push("-f");
|
|
1026
|
+
args.push(worktreePath);
|
|
1027
|
+
await runEffect(cwd, args, { signal: options.signal });
|
|
1028
|
+
},
|
|
1029
|
+
|
|
1030
|
+
async tryRemove(
|
|
1031
|
+
cwd: string,
|
|
1032
|
+
worktreePath: string,
|
|
1033
|
+
options: { force?: boolean; signal?: AbortSignal } = {},
|
|
1034
|
+
): Promise<boolean> {
|
|
1035
|
+
const args = ["worktree", "remove"];
|
|
1036
|
+
if (options.force ?? true) args.push("-f");
|
|
1037
|
+
args.push(worktreePath);
|
|
1038
|
+
const result = await runCommand(cwd, args, { signal: options.signal });
|
|
1039
|
+
return result.exitCode === 0;
|
|
1040
|
+
},
|
|
1041
|
+
|
|
1042
|
+
async list(cwd: string, signal?: AbortSignal): Promise<GitWorktreeEntry[]> {
|
|
1043
|
+
return parseWorktreeList(await runText(cwd, ["worktree", "list", "--porcelain"], { readOnly: true, signal }));
|
|
1044
|
+
},
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1048
|
+
// API: patch
|
|
1049
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1050
|
+
|
|
1051
|
+
export const patch = {
|
|
1052
|
+
/** Apply a patch file. */
|
|
1053
|
+
async apply(cwd: string, patchPath: string, options: PatchOptions = {}): Promise<void> {
|
|
1054
|
+
await runEffect(cwd, buildApplyArgs(patchPath, options), { env: options.env, signal: options.signal });
|
|
1055
|
+
},
|
|
1056
|
+
|
|
1057
|
+
/** Apply a patch from a string (writes to a temp file). */
|
|
1058
|
+
async applyText(cwd: string, patchText: string, options: PatchOptions = {}): Promise<void> {
|
|
1059
|
+
if (!patchText.trim()) return;
|
|
1060
|
+
const tempPath = await writeTempPatch(patchText);
|
|
1061
|
+
try {
|
|
1062
|
+
await patch.apply(cwd, tempPath, options);
|
|
1063
|
+
} finally {
|
|
1064
|
+
await fs.promises.rm(tempPath, { force: true });
|
|
1065
|
+
}
|
|
1066
|
+
},
|
|
1067
|
+
|
|
1068
|
+
/** Check if a patch file can be applied cleanly. */
|
|
1069
|
+
async canApply(cwd: string, patchPath: string, options: Omit<PatchOptions, "check"> = {}): Promise<boolean> {
|
|
1070
|
+
const result = await runCommand(cwd, buildApplyArgs(patchPath, { ...options, check: true }), {
|
|
1071
|
+
env: options.env,
|
|
1072
|
+
readOnly: true,
|
|
1073
|
+
signal: options.signal,
|
|
1074
|
+
});
|
|
1075
|
+
return result.exitCode === 0;
|
|
1076
|
+
},
|
|
1077
|
+
|
|
1078
|
+
/** Check if a patch string can be applied cleanly. */
|
|
1079
|
+
async canApplyText(cwd: string, patchText: string, options: Omit<PatchOptions, "check"> = {}): Promise<boolean> {
|
|
1080
|
+
if (!patchText.trim()) return true;
|
|
1081
|
+
const tempPath = await writeTempPatch(patchText);
|
|
1082
|
+
try {
|
|
1083
|
+
return await patch.canApply(cwd, tempPath, options);
|
|
1084
|
+
} finally {
|
|
1085
|
+
await fs.promises.rm(tempPath, { force: true });
|
|
1086
|
+
}
|
|
1087
|
+
},
|
|
1088
|
+
|
|
1089
|
+
/** Join patch parts into a single patch string. */
|
|
1090
|
+
join(parts: string[]): string {
|
|
1091
|
+
return `${parts
|
|
1092
|
+
.map(part => (part.endsWith("\n") ? part : `${part}\n`))
|
|
1093
|
+
.join("\n")
|
|
1094
|
+
.replace(/\n+$/, "")}\n`;
|
|
1095
|
+
},
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1099
|
+
// API: cherryPick
|
|
1100
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1101
|
+
|
|
1102
|
+
export const cherryPick = Object.assign(
|
|
1103
|
+
async function cherryPick(cwd: string, revision: string, signal?: AbortSignal): Promise<void> {
|
|
1104
|
+
await runEffect(cwd, ["cherry-pick", revision], { signal });
|
|
1105
|
+
},
|
|
1106
|
+
{
|
|
1107
|
+
async abort(cwd: string, signal?: AbortSignal): Promise<void> {
|
|
1108
|
+
await runEffect(cwd, ["cherry-pick", "--abort"], { signal });
|
|
1109
|
+
},
|
|
1110
|
+
},
|
|
1111
|
+
);
|
|
1112
|
+
|
|
1113
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1114
|
+
// API: stash
|
|
1115
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1116
|
+
|
|
1117
|
+
export const stash = {
|
|
1118
|
+
/** Stash working tree + index changes. Returns true when git created a new stash entry. */
|
|
1119
|
+
async push(cwd: string, message?: string): Promise<boolean> {
|
|
1120
|
+
ensureAvailable();
|
|
1121
|
+
const previousStash = await ref.resolve(cwd, "refs/stash");
|
|
1122
|
+
const args = ["stash", "push", "--include-untracked"];
|
|
1123
|
+
if (message) args.push("-m", message);
|
|
1124
|
+
await runEffect(cwd, args);
|
|
1125
|
+
const nextStash = await ref.resolve(cwd, "refs/stash");
|
|
1126
|
+
return nextStash !== null && nextStash !== previousStash;
|
|
1127
|
+
},
|
|
1128
|
+
/** Pop the most recent stash entry, optionally restoring its staged state. */
|
|
1129
|
+
async pop(cwd: string, options?: { index?: boolean }): Promise<void> {
|
|
1130
|
+
const args = ["stash", "pop"];
|
|
1131
|
+
if (options?.index) args.push("--index");
|
|
1132
|
+
await runEffect(cwd, args);
|
|
1133
|
+
},
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1137
|
+
// API: clone, restore, clean
|
|
1138
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1139
|
+
|
|
1140
|
+
export async function clone(url: string, targetDir: string, options: CloneOptions = {}): Promise<void> {
|
|
1141
|
+
ensureAvailable();
|
|
1142
|
+
const absoluteTarget = path.resolve(targetDir);
|
|
1143
|
+
await fs.promises.mkdir(path.dirname(absoluteTarget), { recursive: true });
|
|
1144
|
+
|
|
1145
|
+
const args = ["clone", "--depth", "1"];
|
|
1146
|
+
if (options.ref) args.push("--branch", options.ref, "--single-branch");
|
|
1147
|
+
else args.push("--single-branch");
|
|
1148
|
+
args.push(url, absoluteTarget);
|
|
1149
|
+
|
|
1150
|
+
try {
|
|
1151
|
+
await runEffect(path.dirname(absoluteTarget), args, { signal: options.signal });
|
|
1152
|
+
if (options.sha) {
|
|
1153
|
+
try {
|
|
1154
|
+
await checkout(absoluteTarget, options.sha, options.signal);
|
|
1155
|
+
} catch {
|
|
1156
|
+
await fs.promises.rm(absoluteTarget, { force: true, recursive: true });
|
|
1157
|
+
throw new Error(`Failed to checkout SHA ${options.sha} - shallow clone may not contain this commit`);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
} catch (err) {
|
|
1161
|
+
await fs.promises.rm(absoluteTarget, { force: true, recursive: true });
|
|
1162
|
+
throw err;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
export async function restore(cwd: string, options: RestoreOptions = {}): Promise<void> {
|
|
1167
|
+
const args = ["restore"];
|
|
1168
|
+
if (options.source) args.push(`--source=${options.source}`);
|
|
1169
|
+
if (options.staged) args.push("--staged");
|
|
1170
|
+
if (options.worktree) args.push("--worktree");
|
|
1171
|
+
if (options.files?.length) args.push("--", ...options.files);
|
|
1172
|
+
await runEffect(cwd, args, { signal: options.signal });
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
export async function clean(
|
|
1176
|
+
cwd: string,
|
|
1177
|
+
options: { ignoredOnly?: boolean; paths?: readonly string[]; signal?: AbortSignal } = {},
|
|
1178
|
+
): Promise<void> {
|
|
1179
|
+
const args = ["clean", options.ignoredOnly ? "-fdX" : "-fd"];
|
|
1180
|
+
if (options.paths?.length) args.push("--", ...options.paths);
|
|
1181
|
+
await runEffect(cwd, args, { signal: options.signal });
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1185
|
+
// API: ls
|
|
1186
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1187
|
+
|
|
1188
|
+
export const ls = {
|
|
1189
|
+
/** List files tracked or untracked by git. */
|
|
1190
|
+
async files(
|
|
1191
|
+
cwd: string,
|
|
1192
|
+
options: { others?: boolean; excludeStandard?: boolean; signal?: AbortSignal } = {},
|
|
1193
|
+
): Promise<string[]> {
|
|
1194
|
+
const args = ["ls-files"];
|
|
1195
|
+
if (options.others) args.push("--others");
|
|
1196
|
+
if (options.excludeStandard) args.push("--exclude-standard");
|
|
1197
|
+
return splitLines(await runText(cwd, args, { readOnly: true, signal: options.signal }));
|
|
1198
|
+
},
|
|
1199
|
+
|
|
1200
|
+
/** List untracked files (excludes ignored). */
|
|
1201
|
+
async untracked(cwd: string, signal?: AbortSignal): Promise<string[]> {
|
|
1202
|
+
return ls.files(cwd, { others: true, excludeStandard: true, signal });
|
|
1203
|
+
},
|
|
1204
|
+
|
|
1205
|
+
/** List submodule paths (recursive). */
|
|
1206
|
+
async submodules(cwd: string, signal?: AbortSignal): Promise<string[]> {
|
|
1207
|
+
const output = await runCommand(cwd, ["submodule", "--quiet", "foreach", "--recursive", "echo $sm_path"], {
|
|
1208
|
+
readOnly: true,
|
|
1209
|
+
signal,
|
|
1210
|
+
});
|
|
1211
|
+
return splitLines(output.stdout);
|
|
1212
|
+
},
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1216
|
+
// API: head
|
|
1217
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1218
|
+
|
|
1219
|
+
export const head = {
|
|
1220
|
+
/** Full HEAD state (branch, commit, repo info). */
|
|
1221
|
+
async resolve(cwd: string): Promise<GitHeadState | null> {
|
|
1222
|
+
const repository = await resolveRepository(cwd);
|
|
1223
|
+
if (!repository) return null;
|
|
1224
|
+
const content = await readOptionalText(repository.headPath);
|
|
1225
|
+
if (content === null) return null;
|
|
1226
|
+
return parseHeadState(repository, content);
|
|
1227
|
+
},
|
|
1228
|
+
|
|
1229
|
+
/** Full HEAD state (synchronous). */
|
|
1230
|
+
resolveSync(cwd: string): GitHeadState | null {
|
|
1231
|
+
const repository = resolveRepositorySync(cwd);
|
|
1232
|
+
if (!repository) return null;
|
|
1233
|
+
const content = readOptionalTextSync(repository.headPath);
|
|
1234
|
+
if (content === null) return null;
|
|
1235
|
+
return parseHeadStateSync(repository, content);
|
|
1236
|
+
},
|
|
1237
|
+
|
|
1238
|
+
/** Current HEAD commit SHA. */
|
|
1239
|
+
async sha(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
1240
|
+
const headState = await head.resolve(cwd);
|
|
1241
|
+
if (headState?.commit) return headState.commit;
|
|
1242
|
+
const result = await runCommand(cwd, ["rev-parse", "HEAD"], { readOnly: true, signal });
|
|
1243
|
+
if (result.exitCode !== 0) return null;
|
|
1244
|
+
return result.stdout.trim() || null;
|
|
1245
|
+
},
|
|
1246
|
+
|
|
1247
|
+
/** Abbreviated HEAD commit SHA. */
|
|
1248
|
+
async short(cwd: string, length = 7, signal?: AbortSignal): Promise<string | null> {
|
|
1249
|
+
const result = await runCommand(cwd, ["rev-parse", `--short=${length}`, "HEAD"], { readOnly: true, signal });
|
|
1250
|
+
if (result.exitCode !== 0) return null;
|
|
1251
|
+
return result.stdout.trim() || null;
|
|
1252
|
+
},
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1256
|
+
// API: repo
|
|
1257
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1258
|
+
|
|
1259
|
+
export const repo = {
|
|
1260
|
+
/** Resolve the repository root (may be a worktree root). */
|
|
1261
|
+
async root(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
1262
|
+
const repository = await resolveRepository(cwd);
|
|
1263
|
+
if (repository) return repository.repoRoot;
|
|
1264
|
+
const result = await runCommand(cwd, ["rev-parse", "--show-toplevel"], { readOnly: true, signal });
|
|
1265
|
+
if (result.exitCode !== 0) return null;
|
|
1266
|
+
return result.stdout.trim() || null;
|
|
1267
|
+
},
|
|
1268
|
+
|
|
1269
|
+
/** Resolve the primary repository root (not a worktree — the main checkout). */
|
|
1270
|
+
async primaryRoot(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
1271
|
+
const repository = await resolveRepository(cwd);
|
|
1272
|
+
if (repository) {
|
|
1273
|
+
if (path.basename(repository.commonDir) === ".git") return path.dirname(repository.commonDir);
|
|
1274
|
+
return repository.repoRoot;
|
|
1275
|
+
}
|
|
1276
|
+
const repoRoot = await repo.root(cwd, signal);
|
|
1277
|
+
if (!repoRoot) return null;
|
|
1278
|
+
const commonDir = await runText(repoRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
1279
|
+
readOnly: true,
|
|
1280
|
+
signal,
|
|
1281
|
+
});
|
|
1282
|
+
if (path.basename(commonDir.trim()) === ".git") return path.dirname(commonDir.trim());
|
|
1283
|
+
return repoRoot;
|
|
1284
|
+
},
|
|
1285
|
+
|
|
1286
|
+
/** Full GitRepository metadata (sync). */
|
|
1287
|
+
resolveSync(cwd: string): GitRepository | null {
|
|
1288
|
+
return resolveRepositorySync(cwd);
|
|
1289
|
+
},
|
|
1290
|
+
|
|
1291
|
+
/** Full GitRepository metadata. */
|
|
1292
|
+
resolve(cwd: string): Promise<GitRepository | null> {
|
|
1293
|
+
return resolveRepository(cwd);
|
|
1294
|
+
},
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
// Helper used during head resolution — defined here to reference `head` namespace.
|
|
1298
|
+
async function resolveHead(cwd: string): Promise<GitHeadState | null> {
|
|
1299
|
+
return head.resolve(cwd);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1303
|
+
// API: github (GitHub CLI)
|
|
1304
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1305
|
+
|
|
1306
|
+
export interface GhCommandResult {
|
|
1307
|
+
exitCode: number;
|
|
1308
|
+
stdout: string;
|
|
1309
|
+
stderr: string;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
export interface GhCommandOptions {
|
|
1313
|
+
repoProvided?: boolean;
|
|
1314
|
+
trimOutput?: boolean;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function formatGhFailure(args: readonly string[], stdout: string, stderr: string, options?: GhCommandOptions): string {
|
|
1318
|
+
const message = (stderr || stdout).trim();
|
|
1319
|
+
if (message.includes("gh auth login") || message.includes("not logged into any GitHub hosts")) {
|
|
1320
|
+
return "GitHub CLI is not authenticated. Run `gh auth login`.";
|
|
1321
|
+
}
|
|
1322
|
+
if (
|
|
1323
|
+
!options?.repoProvided &&
|
|
1324
|
+
(message.includes("not a git repository") ||
|
|
1325
|
+
message.includes("no git remotes found") ||
|
|
1326
|
+
message.includes("unable to determine current repository"))
|
|
1327
|
+
) {
|
|
1328
|
+
return "GitHub repository context is unavailable. Pass `repo` explicitly or run the tool inside a GitHub checkout.";
|
|
1329
|
+
}
|
|
1330
|
+
if (message.length > 0) return message;
|
|
1331
|
+
return `GitHub CLI command failed: gh ${args.join(" ")}`;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
export const github = {
|
|
1335
|
+
/** Check if `gh` CLI is installed. */
|
|
1336
|
+
available(): boolean {
|
|
1337
|
+
return Boolean($which("gh"));
|
|
1338
|
+
},
|
|
1339
|
+
|
|
1340
|
+
/** Run a raw `gh` CLI command. Does not throw on non-zero exit. */
|
|
1341
|
+
async run(cwd: string, args: string[], signal?: AbortSignal, options?: GhCommandOptions): Promise<GhCommandResult> {
|
|
1342
|
+
throwIfAborted(signal);
|
|
1343
|
+
if (!$which("gh")) {
|
|
1344
|
+
throw new ToolError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/.");
|
|
1345
|
+
}
|
|
1346
|
+
try {
|
|
1347
|
+
const child = Bun.spawn(["gh", ...args], {
|
|
1348
|
+
cwd,
|
|
1349
|
+
stdin: "ignore",
|
|
1350
|
+
stdout: "pipe",
|
|
1351
|
+
stderr: "pipe",
|
|
1352
|
+
windowsHide: true,
|
|
1353
|
+
signal,
|
|
1354
|
+
});
|
|
1355
|
+
if (!child.stdout || !child.stderr) {
|
|
1356
|
+
throw new ToolError("Failed to capture GitHub CLI output.");
|
|
1357
|
+
}
|
|
1358
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1359
|
+
new Response(child.stdout).text(),
|
|
1360
|
+
new Response(child.stderr).text(),
|
|
1361
|
+
child.exited,
|
|
1362
|
+
]);
|
|
1363
|
+
throwIfAborted(signal);
|
|
1364
|
+
const trim = options?.trimOutput !== false;
|
|
1365
|
+
return {
|
|
1366
|
+
exitCode: exitCode ?? 0,
|
|
1367
|
+
stdout: trim ? stdout.trim() : stdout,
|
|
1368
|
+
stderr: trim ? stderr.trim() : stderr,
|
|
1369
|
+
};
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
if (signal?.aborted) throw new ToolAbortError();
|
|
1372
|
+
throw error;
|
|
1373
|
+
}
|
|
1374
|
+
},
|
|
1375
|
+
|
|
1376
|
+
/** Run `gh` and parse stdout as JSON. Throws on non-zero exit or invalid JSON. */
|
|
1377
|
+
async json<T>(cwd: string, args: string[], signal?: AbortSignal, options?: GhCommandOptions): Promise<T> {
|
|
1378
|
+
const result = await github.run(cwd, args, signal, options);
|
|
1379
|
+
if (result.exitCode !== 0) {
|
|
1380
|
+
throw new ToolError(formatGhFailure(args, result.stdout, result.stderr, options));
|
|
1381
|
+
}
|
|
1382
|
+
if (!result.stdout) {
|
|
1383
|
+
throw new ToolError("GitHub CLI returned empty output.");
|
|
1384
|
+
}
|
|
1385
|
+
try {
|
|
1386
|
+
return JSON.parse(result.stdout) as T;
|
|
1387
|
+
} catch {
|
|
1388
|
+
throw new ToolError("GitHub CLI returned invalid JSON output.");
|
|
1389
|
+
}
|
|
1390
|
+
},
|
|
1391
|
+
|
|
1392
|
+
/** Run `gh` and return stdout as text. Throws on non-zero exit. */
|
|
1393
|
+
async text(cwd: string, args: string[], signal?: AbortSignal, options?: GhCommandOptions): Promise<string> {
|
|
1394
|
+
const result = await github.run(cwd, args, signal, options);
|
|
1395
|
+
if (result.exitCode !== 0) {
|
|
1396
|
+
throw new ToolError(formatGhFailure(args, result.stdout, result.stderr, options));
|
|
1397
|
+
}
|
|
1398
|
+
return result.stdout;
|
|
1399
|
+
},
|
|
1400
|
+
};
|