@senad-d/branchme 0.1.5 → 0.1.7
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/.env.example +6 -2
- package/CHANGELOG.md +3 -2
- package/README.md +46 -21
- package/SECURITY.md +12 -8
- package/docs/PROJECT_DEFINITION_BRIEF.md +4 -4
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +11 -9
- package/docs/TUI_CAPTURE.md +29 -9
- package/package.json +3 -3
- package/src/commands/branchme-command.ts +12 -8
- package/src/constants.ts +9 -0
- package/src/git-context.ts +22 -2
- package/src/git.ts +225 -3
- package/src/github.ts +76 -27
- package/src/tools/branchme-tools.ts +195 -21
- package/src/types.ts +29 -0
- package/src/ui/branchme-panel.ts +5 -3
package/src/constants.ts
CHANGED
|
@@ -4,22 +4,29 @@ export const BRANCHME_COMMAND_NAME = "branchme";
|
|
|
4
4
|
export const BRANCH_STATUS_TOOL_NAME = "branch_status";
|
|
5
5
|
export const CREATE_BRANCH_TOOL_NAME = "create_branch";
|
|
6
6
|
export const CHANGE_BRANCH_TOOL_NAME = "change_branch";
|
|
7
|
+
export const FETCH_BRANCH_TOOL_NAME = "fetch_branch";
|
|
7
8
|
export const PULL_BRANCH_TOOL_NAME = "pull_branch";
|
|
9
|
+
export const REBASE_BRANCH_TOOL_NAME = "rebase_branch";
|
|
8
10
|
export const PUSH_BRANCH_TOOL_NAME = "push_branch";
|
|
9
11
|
export const PULL_REQUEST_TOOL_NAME = "pull_request";
|
|
12
|
+
export const PULL_REQUEST_AUTOFILL_ENV_NAME = "BRANCHME_PR_AUTOFILL";
|
|
10
13
|
|
|
11
14
|
export const BRANCHME_TOOL_NAMES = [
|
|
12
15
|
BRANCH_STATUS_TOOL_NAME,
|
|
13
16
|
CREATE_BRANCH_TOOL_NAME,
|
|
14
17
|
CHANGE_BRANCH_TOOL_NAME,
|
|
18
|
+
FETCH_BRANCH_TOOL_NAME,
|
|
15
19
|
PULL_BRANCH_TOOL_NAME,
|
|
20
|
+
REBASE_BRANCH_TOOL_NAME,
|
|
16
21
|
PUSH_BRANCH_TOOL_NAME,
|
|
17
22
|
PULL_REQUEST_TOOL_NAME,
|
|
18
23
|
] as const;
|
|
19
24
|
|
|
20
25
|
export const GIT_STATUS_TIMEOUT_MS = 5_000;
|
|
21
26
|
export const GIT_MUTATION_TIMEOUT_MS = 30_000;
|
|
27
|
+
export const GIT_FETCH_TIMEOUT_MS = 120_000;
|
|
22
28
|
export const GIT_PULL_TIMEOUT_MS = 120_000;
|
|
29
|
+
export const GIT_REBASE_TIMEOUT_MS = 120_000;
|
|
23
30
|
export const GIT_PUSH_TIMEOUT_MS = 120_000;
|
|
24
31
|
export const GITHUB_RELATED_PR_TIMEOUT_MS = 4_000;
|
|
25
32
|
|
|
@@ -27,6 +34,8 @@ export const GIT_CONTEXT_RECENT_COMMIT_LIMIT = 5;
|
|
|
27
34
|
export const GIT_CONTEXT_CHANGE_LIMIT = 20;
|
|
28
35
|
export const GIT_CONTEXT_VALUE_LIMIT_CHARS = 512;
|
|
29
36
|
export const GIT_CONTEXT_SUMMARY_LIMIT_CHARS = 4_000;
|
|
37
|
+
export const PULL_REQUEST_AUTOFILL_COMMIT_LIMIT = 20;
|
|
38
|
+
export const PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS = 256;
|
|
30
39
|
|
|
31
40
|
export const GITHUB_API_BASE_URL = "https://api.github.com";
|
|
32
41
|
export const GITHUB_API_VERSION = "2022-11-28";
|
package/src/git-context.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
getWorkingTreeStatus,
|
|
20
20
|
type GitCommandContext,
|
|
21
21
|
} from "./git.ts";
|
|
22
|
-
import { lookupRelatedPullRequest } from "./github.ts";
|
|
22
|
+
import { lookupRelatedPullRequest, resolvePullRequestAutofill } from "./github.ts";
|
|
23
23
|
import { redactSecrets } from "./redaction.ts";
|
|
24
24
|
import type { GitContextDetails, GitFileChange, RecentCommit, RelatedPullRequest } from "./types.ts";
|
|
25
25
|
|
|
@@ -138,6 +138,7 @@ export async function collectGitContext(
|
|
|
138
138
|
const recentCommits = await getRecentCommits(pi, rootCtx, options.signal);
|
|
139
139
|
const upstream = current.detached ? null : await getUpstreamBranch(pi, rootCtx, options.signal);
|
|
140
140
|
const warnings: string[] = [];
|
|
141
|
+
let pullRequestAutofill: boolean | null = null;
|
|
141
142
|
let ahead: number | null = null;
|
|
142
143
|
let behind: number | null = null;
|
|
143
144
|
|
|
@@ -152,6 +153,13 @@ export async function collectGitContext(
|
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
try {
|
|
157
|
+
pullRequestAutofill = await resolvePullRequestAutofill(options.env, { cwd: repoRoot, signal: options.signal });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throwIfCollectionAborted(options.signal);
|
|
160
|
+
warnings.push(`pull request autofill configuration unavailable (${safeFailureType(error)})`);
|
|
161
|
+
}
|
|
162
|
+
|
|
155
163
|
const relatedPullRequest = await collectRelatedPullRequest(pi, rootCtx, options);
|
|
156
164
|
|
|
157
165
|
return {
|
|
@@ -166,6 +174,7 @@ export async function collectGitContext(
|
|
|
166
174
|
...(relatedPullRequest.status === "found"
|
|
167
175
|
? { githubRepository: relatedPullRequest.pullRequest.repository }
|
|
168
176
|
: {}),
|
|
177
|
+
pullRequestAutofill,
|
|
169
178
|
workingTree: workingTreeStatus.workingTree,
|
|
170
179
|
unstagedChanges: workingTreeStatus.unstagedChanges,
|
|
171
180
|
relatedPullRequest,
|
|
@@ -223,6 +232,13 @@ function formatRelatedPullRequest(details: GitContextDetails, valueLimit: number
|
|
|
223
232
|
return `- Related PR: #${safeNonNegativeInteger(pullRequest.number)} ${quoteMetadata(pullRequest.title, valueLimit)}; repository ${quoteMetadata(repository, valueLimit)}; ${quoteMetadata(pullRequest.head, valueLimit)} -> ${quoteMetadata(pullRequest.base, valueLimit)}; ${quoteMetadata(pullRequest.url, valueLimit)}; ${quoteMetadata(pullRequest.state, valueLimit)}${draft}`;
|
|
224
233
|
}
|
|
225
234
|
|
|
235
|
+
function formatPullRequestAutofill(details: GitContextDetails): string {
|
|
236
|
+
let state = "unavailable";
|
|
237
|
+
if (details.pullRequestAutofill === true) state = "enabled";
|
|
238
|
+
if (details.pullRequestAutofill === false) state = "disabled";
|
|
239
|
+
return `- Pull request field autofill: ${state} (BRANCHME_PR_AUTOFILL)`;
|
|
240
|
+
}
|
|
241
|
+
|
|
226
242
|
function appendRecentCommits(lines: string[], selection: FormatSelection): void {
|
|
227
243
|
if (selection.commits.length === 0 && selection.omittedCommits === 0) {
|
|
228
244
|
lines.push("- Recent commits: none");
|
|
@@ -257,7 +273,10 @@ function renderGitContext(
|
|
|
257
273
|
formatWorkingTree(details),
|
|
258
274
|
];
|
|
259
275
|
appendUnstagedChanges(lines, selection);
|
|
260
|
-
lines.push(
|
|
276
|
+
lines.push(
|
|
277
|
+
formatRelatedPullRequest(details, selection.valueLimit),
|
|
278
|
+
formatPullRequestAutofill(details),
|
|
279
|
+
);
|
|
261
280
|
appendRecentCommits(lines, selection);
|
|
262
281
|
lines.push(
|
|
263
282
|
"",
|
|
@@ -295,6 +314,7 @@ function boundedFallbackContext(mode: GitContextFormatMode): string {
|
|
|
295
314
|
"- Working tree: unavailable",
|
|
296
315
|
"- Unstaged changes: [entries omitted]",
|
|
297
316
|
"- Related PR: unavailable",
|
|
317
|
+
"- Pull request field autofill: unavailable",
|
|
298
318
|
"- Recent commits: [entries omitted]",
|
|
299
319
|
"",
|
|
300
320
|
"Call branch_status to request a current bounded snapshot.",
|
package/src/git.ts
CHANGED
|
@@ -3,11 +3,15 @@ import {
|
|
|
3
3
|
GIT_CONTEXT_CHANGE_LIMIT,
|
|
4
4
|
GIT_CONTEXT_RECENT_COMMIT_LIMIT,
|
|
5
5
|
GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
6
|
+
GIT_FETCH_TIMEOUT_MS,
|
|
6
7
|
GIT_MUTATION_TIMEOUT_MS,
|
|
7
8
|
GIT_PULL_TIMEOUT_MS,
|
|
8
9
|
GIT_PUSH_TIMEOUT_MS,
|
|
10
|
+
GIT_REBASE_TIMEOUT_MS,
|
|
9
11
|
GIT_STATUS_TIMEOUT_MS,
|
|
10
12
|
MAX_SUMMARY_OUTPUT_CHARS,
|
|
13
|
+
PULL_REQUEST_AUTOFILL_COMMIT_LIMIT,
|
|
14
|
+
PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS,
|
|
11
15
|
} from "./constants.ts";
|
|
12
16
|
import { redactSecrets } from "./redaction.ts";
|
|
13
17
|
import type {
|
|
@@ -16,11 +20,13 @@ import type {
|
|
|
16
20
|
ChangeBranchDetails,
|
|
17
21
|
CreateBranchDetails,
|
|
18
22
|
CurrentBranchInfo,
|
|
23
|
+
FetchBranchDetails,
|
|
19
24
|
GitExecResult,
|
|
20
25
|
GitFileChange,
|
|
21
26
|
GitFileChangeSummary,
|
|
22
27
|
PullBranchDetails,
|
|
23
28
|
PushBranchDetails,
|
|
29
|
+
RebaseBranchDetails,
|
|
24
30
|
RecentCommit,
|
|
25
31
|
WorkingTreeDetails,
|
|
26
32
|
} from "./types.ts";
|
|
@@ -33,6 +39,7 @@ export interface GitRunOptions {
|
|
|
33
39
|
signal?: AbortSignal;
|
|
34
40
|
timeout?: number;
|
|
35
41
|
allowFailure?: boolean;
|
|
42
|
+
tokens?: readonly string[];
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
const repositoryMutationQueues = new Map<string, Promise<void>>();
|
|
@@ -110,11 +117,11 @@ export async function runGit(
|
|
|
110
117
|
});
|
|
111
118
|
|
|
112
119
|
if (result.killed) {
|
|
113
|
-
throw new Error(formatGitFailure(args, result));
|
|
120
|
+
throw new Error(formatGitFailure(args, result, options.tokens));
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
if (!options.allowFailure && result.code !== 0) {
|
|
117
|
-
throw new Error(formatGitFailure(args, result));
|
|
124
|
+
throw new Error(formatGitFailure(args, result, options.tokens));
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
return result;
|
|
@@ -199,7 +206,7 @@ interface PushTarget {
|
|
|
199
206
|
args: string[];
|
|
200
207
|
}
|
|
201
208
|
|
|
202
|
-
type UpstreamOperation = "pull" | "push";
|
|
209
|
+
type UpstreamOperation = "fetch" | "pull" | "push" | "rebase";
|
|
203
210
|
|
|
204
211
|
function validateRemoteName(remote: string, operation: UpstreamOperation): void {
|
|
205
212
|
if (!remote) throw new Error(`Unable to ${operation} current branch: upstream remote is missing.`);
|
|
@@ -241,6 +248,17 @@ async function getBranchConfigValue(
|
|
|
241
248
|
return value || null;
|
|
242
249
|
}
|
|
243
250
|
|
|
251
|
+
function remoteTrackingRef(upstream: string, remote: string): string {
|
|
252
|
+
const prefix = `${remote}/`;
|
|
253
|
+
if (!upstream.startsWith(prefix)) {
|
|
254
|
+
throw new Error("Unable to fetch current branch: configured upstream does not match its remote.");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const trackingBranch = upstream.slice(prefix.length);
|
|
258
|
+
validateBranchNameInput(trackingBranch, "Upstream branch name");
|
|
259
|
+
return `refs/remotes/${upstream}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
244
262
|
async function resolveConfiguredUpstreamTarget(
|
|
245
263
|
pi: Pick<ExtensionAPI, "exec">,
|
|
246
264
|
ctx: GitCommandContext,
|
|
@@ -553,6 +571,98 @@ export async function getRecentCommits(
|
|
|
553
571
|
throw new Error(formatGitFailure(args, result));
|
|
554
572
|
}
|
|
555
573
|
|
|
574
|
+
function truncatePullRequestCommitSubject(value: string): string {
|
|
575
|
+
if (value.length <= PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS) return value;
|
|
576
|
+
|
|
577
|
+
let end = PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS - 1;
|
|
578
|
+
const lastCodePoint = value.codePointAt(end - 1);
|
|
579
|
+
if (lastCodePoint !== undefined && lastCodePoint > 0xffff) end -= 1;
|
|
580
|
+
return `${value.slice(0, end).trimEnd()}…`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function safePullRequestCommitSubject(value: string, tokens: readonly string[]): string {
|
|
584
|
+
const normalized = redactSecrets(value, tokens)
|
|
585
|
+
.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
|
|
586
|
+
.replace(/\s+/gu, " ")
|
|
587
|
+
.trim();
|
|
588
|
+
return truncatePullRequestCommitSubject(normalized);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function getOriginDefaultBranch(
|
|
592
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
593
|
+
ctx: GitCommandContext,
|
|
594
|
+
signal?: AbortSignal,
|
|
595
|
+
): Promise<string | null> {
|
|
596
|
+
const args = ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"];
|
|
597
|
+
const result = await runGit(pi, ctx, args, {
|
|
598
|
+
signal,
|
|
599
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
600
|
+
allowFailure: true,
|
|
601
|
+
});
|
|
602
|
+
if (result.code !== 0) return null;
|
|
603
|
+
|
|
604
|
+
const remoteBranch = trimOutput(result.stdout);
|
|
605
|
+
const prefix = "origin/";
|
|
606
|
+
if (!remoteBranch.startsWith(prefix)) return null;
|
|
607
|
+
|
|
608
|
+
const branch = remoteBranch.slice(prefix.length);
|
|
609
|
+
try {
|
|
610
|
+
validateBranchNameInput(branch, "Default base branch");
|
|
611
|
+
} catch {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
return branch;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export async function inferPullRequestBaseBranch(
|
|
618
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
619
|
+
ctx: GitCommandContext,
|
|
620
|
+
headBranch: string,
|
|
621
|
+
signal?: AbortSignal,
|
|
622
|
+
): Promise<string> {
|
|
623
|
+
validateBranchNameInput(headBranch, "headBranch");
|
|
624
|
+
const originDefault = await getOriginDefaultBranch(pi, ctx, signal);
|
|
625
|
+
if (originDefault) {
|
|
626
|
+
if (originDefault === headBranch) {
|
|
627
|
+
throw new Error("Unable to infer baseBranch because the current branch is the origin default branch.");
|
|
628
|
+
}
|
|
629
|
+
if (await localBranchExists(pi, ctx, originDefault, signal)) return originDefault;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
for (const candidate of ["main", "master", "trunk", "develop"]) {
|
|
633
|
+
if (candidate !== headBranch && await localBranchExists(pi, ctx, candidate, signal)) return candidate;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
throw new Error(
|
|
637
|
+
"Unable to infer baseBranch from origin/HEAD or a local main, master, trunk, or develop branch; provide baseBranch explicitly.",
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export async function getPullRequestCommitSubjects(
|
|
642
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
643
|
+
ctx: GitCommandContext,
|
|
644
|
+
headBranch: string,
|
|
645
|
+
baseBranch: string,
|
|
646
|
+
signal?: AbortSignal,
|
|
647
|
+
tokens: readonly string[] = [],
|
|
648
|
+
): Promise<string[]> {
|
|
649
|
+
validateBranchNameInput(headBranch, "headBranch");
|
|
650
|
+
validateBranchNameInput(baseBranch, "baseBranch");
|
|
651
|
+
const revisionRange = `refs/heads/${baseBranch}..refs/heads/${headBranch}`;
|
|
652
|
+
const args = [
|
|
653
|
+
"log",
|
|
654
|
+
`--max-count=${PULL_REQUEST_AUTOFILL_COMMIT_LIMIT}`,
|
|
655
|
+
"--format=%s",
|
|
656
|
+
revisionRange,
|
|
657
|
+
"--",
|
|
658
|
+
];
|
|
659
|
+
const result = await runGit(pi, ctx, args, { signal, timeout: GIT_STATUS_TIMEOUT_MS, tokens });
|
|
660
|
+
return result.stdout
|
|
661
|
+
.split(/\r?\n/u)
|
|
662
|
+
.map((subject) => safePullRequestCommitSubject(subject, tokens))
|
|
663
|
+
.filter(Boolean);
|
|
664
|
+
}
|
|
665
|
+
|
|
556
666
|
export async function getAheadBehindCount(
|
|
557
667
|
pi: Pick<ExtensionAPI, "exec">,
|
|
558
668
|
ctx: GitCommandContext,
|
|
@@ -700,6 +810,46 @@ export async function changeExistingLocalBranch(
|
|
|
700
810
|
});
|
|
701
811
|
}
|
|
702
812
|
|
|
813
|
+
export async function fetchCurrentBranch(
|
|
814
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
815
|
+
ctx: GitCommandContext,
|
|
816
|
+
signal?: AbortSignal,
|
|
817
|
+
): Promise<FetchBranchDetails> {
|
|
818
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
819
|
+
const rootCtx = { cwd: repoRoot };
|
|
820
|
+
|
|
821
|
+
return withRepositoryMutationQueue(repoRoot, async () => {
|
|
822
|
+
const currentBranch = await requireCurrentBranch(pi, rootCtx, signal);
|
|
823
|
+
const target = await resolveConfiguredUpstreamTarget(pi, rootCtx, currentBranch, "fetch", signal);
|
|
824
|
+
if (!target) {
|
|
825
|
+
throw new Error(`Unable to fetch current branch '${safeDetail(currentBranch)}': no upstream is configured.`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const trackingRef = remoteTrackingRef(target.upstream, target.remote);
|
|
829
|
+
const refspec = `${target.remoteRef}:${trackingRef}`;
|
|
830
|
+
const result = await runGit(
|
|
831
|
+
pi,
|
|
832
|
+
rootCtx,
|
|
833
|
+
["fetch", "--no-tags", "--no-recurse-submodules", target.remote, refspec],
|
|
834
|
+
{
|
|
835
|
+
signal,
|
|
836
|
+
timeout: GIT_FETCH_TIMEOUT_MS,
|
|
837
|
+
},
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
repoRoot,
|
|
842
|
+
currentBranch: safeDetail(currentBranch),
|
|
843
|
+
upstream: safeDetail(target.upstream),
|
|
844
|
+
remote: safeDetail(target.remote),
|
|
845
|
+
remoteRef: safeDetail(target.remoteRef),
|
|
846
|
+
remoteTrackingRef: safeDetail(trackingRef),
|
|
847
|
+
refspec: safeDetail(refspec),
|
|
848
|
+
output: safeOutput(result.stdout || result.stderr),
|
|
849
|
+
};
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
703
853
|
export async function pullCurrentBranch(
|
|
704
854
|
pi: Pick<ExtensionAPI, "exec">,
|
|
705
855
|
ctx: GitCommandContext,
|
|
@@ -735,6 +885,78 @@ export async function pullCurrentBranch(
|
|
|
735
885
|
});
|
|
736
886
|
}
|
|
737
887
|
|
|
888
|
+
async function abortFailedRebase(
|
|
889
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
890
|
+
ctx: GitCommandContext,
|
|
891
|
+
failureMessage: string,
|
|
892
|
+
): Promise<never> {
|
|
893
|
+
const abortArgs = ["rebase", "--abort"];
|
|
894
|
+
let abortResult: GitExecResult;
|
|
895
|
+
try {
|
|
896
|
+
abortResult = await runGit(pi, ctx, abortArgs, {
|
|
897
|
+
timeout: GIT_MUTATION_TIMEOUT_MS,
|
|
898
|
+
allowFailure: true,
|
|
899
|
+
});
|
|
900
|
+
} catch (error) {
|
|
901
|
+
const reason = safeOutput(error instanceof Error ? error.message : String(error)) || "git rebase --abort failed";
|
|
902
|
+
throw new Error(`${failureMessage}. Automatic rebase cleanup failed: ${reason}. Inspect the repository before continuing.`);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (abortResult.code === 0) {
|
|
906
|
+
throw new Error(`${failureMessage}. Rebase was aborted and the current branch was restored.`);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const reason = safeOutput(abortResult.stderr || abortResult.stdout) || `exit code ${abortResult.code}`;
|
|
910
|
+
throw new Error(`${failureMessage}. Automatic git rebase --abort did not complete: ${reason}. Inspect the repository before continuing.`);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export async function rebaseCurrentBranch(
|
|
914
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
915
|
+
ctx: GitCommandContext,
|
|
916
|
+
signal?: AbortSignal,
|
|
917
|
+
): Promise<RebaseBranchDetails> {
|
|
918
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
919
|
+
const rootCtx = { cwd: repoRoot };
|
|
920
|
+
|
|
921
|
+
return withRepositoryMutationQueue(repoRoot, async () => {
|
|
922
|
+
const currentBranch = await requireCurrentBranch(pi, rootCtx, signal);
|
|
923
|
+
if (await hasWorkingTreeChanges(pi, rootCtx, signal)) {
|
|
924
|
+
throw new Error("Working tree has uncommitted changes; clean it before rebasing the current branch.");
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const target = await resolveConfiguredUpstreamTarget(pi, rootCtx, currentBranch, "rebase", signal);
|
|
928
|
+
if (!target) {
|
|
929
|
+
throw new Error(`Unable to rebase current branch '${safeDetail(currentBranch)}': no upstream is configured.`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const args = ["rebase", "--no-autostash", "--no-update-refs", target.upstream];
|
|
933
|
+
let result: GitExecResult;
|
|
934
|
+
try {
|
|
935
|
+
result = await runGit(pi, rootCtx, args, {
|
|
936
|
+
signal,
|
|
937
|
+
timeout: GIT_REBASE_TIMEOUT_MS,
|
|
938
|
+
allowFailure: true,
|
|
939
|
+
});
|
|
940
|
+
} catch (error) {
|
|
941
|
+
const failureMessage = safeOutput(error instanceof Error ? error.message : String(error)) || "git rebase failed";
|
|
942
|
+
return abortFailedRebase(pi, rootCtx, failureMessage);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (result.code !== 0) {
|
|
946
|
+
return abortFailedRebase(pi, rootCtx, formatGitFailure(args, result));
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return {
|
|
950
|
+
repoRoot,
|
|
951
|
+
currentBranch: safeDetail(currentBranch),
|
|
952
|
+
upstream: safeDetail(target.upstream),
|
|
953
|
+
remote: safeDetail(target.remote),
|
|
954
|
+
remoteRef: safeDetail(target.remoteRef),
|
|
955
|
+
output: safeOutput(result.stdout || result.stderr),
|
|
956
|
+
};
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
738
960
|
export async function pushCurrentBranch(
|
|
739
961
|
pi: Pick<ExtensionAPI, "exec">,
|
|
740
962
|
ctx: GitCommandContext,
|
package/src/github.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
GITHUB_USER_AGENT,
|
|
11
11
|
GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
12
12
|
MAX_SUMMARY_OUTPUT_CHARS,
|
|
13
|
+
PULL_REQUEST_AUTOFILL_ENV_NAME,
|
|
13
14
|
} from "./constants.ts";
|
|
14
15
|
import {
|
|
15
16
|
getCurrentBranch,
|
|
@@ -27,6 +28,9 @@ import type {
|
|
|
27
28
|
} from "./types.ts";
|
|
28
29
|
|
|
29
30
|
type TokenEnvironmentKey = "GITHUB_TOKEN" | "GH_TOKEN";
|
|
31
|
+
type PullRequestAutofillEnvironmentKey = typeof PULL_REQUEST_AUTOFILL_ENV_NAME;
|
|
32
|
+
type SupportedDotEnvKey = TokenEnvironmentKey | PullRequestAutofillEnvironmentKey;
|
|
33
|
+
type DotEnvReadPurpose = "GitHub token fallback" | "pull request autofill configuration";
|
|
30
34
|
|
|
31
35
|
export type TokenResolutionSource = TokenEnvironmentKey | `${TokenEnvironmentKey} (.env)`;
|
|
32
36
|
|
|
@@ -149,8 +153,8 @@ function resolveProcessToken(env: NodeJS.ProcessEnv): TokenResolution | null {
|
|
|
149
153
|
return null;
|
|
150
154
|
}
|
|
151
155
|
|
|
152
|
-
function
|
|
153
|
-
return value === "GITHUB_TOKEN" || value === "GH_TOKEN";
|
|
156
|
+
function isSupportedDotEnvKey(value: string): value is SupportedDotEnvKey {
|
|
157
|
+
return value === "GITHUB_TOKEN" || value === "GH_TOKEN" || value === PULL_REQUEST_AUTOFILL_ENV_NAME;
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
function decodeDoubleQuotedDotEnvValue(value: string): string {
|
|
@@ -182,23 +186,46 @@ function stripDotEnvInlineComment(value: string): string {
|
|
|
182
186
|
return value;
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
function closingDotEnvQuoteIndex(value: string, quote: "\"" | "'"): number {
|
|
190
|
+
let escaped = false;
|
|
191
|
+
for (let index = 1; index < value.length; index += 1) {
|
|
192
|
+
const character = value.charAt(index);
|
|
193
|
+
if (quote === "\"" && character === "\\" && !escaped) {
|
|
194
|
+
escaped = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (character === quote && !escaped) return index;
|
|
198
|
+
escaped = false;
|
|
199
|
+
}
|
|
200
|
+
return -1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseQuotedDotEnvValue(value: string): string | null {
|
|
204
|
+
const quote = value.charAt(0);
|
|
205
|
+
if (quote !== "\"" && quote !== "'") return null;
|
|
206
|
+
|
|
207
|
+
const closingIndex = closingDotEnvQuoteIndex(value, quote);
|
|
208
|
+
if (closingIndex === -1) return null;
|
|
209
|
+
|
|
210
|
+
const remainder = value.slice(closingIndex + 1).trim();
|
|
211
|
+
if (remainder && !remainder.startsWith("#")) return null;
|
|
212
|
+
|
|
213
|
+
const quotedValue = value.slice(1, closingIndex);
|
|
214
|
+
return quote === "\"" ? decodeDoubleQuotedDotEnvValue(quotedValue).trim() : quotedValue.trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
185
217
|
function parseDotEnvValue(rawValue: string): string {
|
|
186
218
|
const value = rawValue.trim();
|
|
187
219
|
if (!value) return "";
|
|
188
220
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (value.startsWith("'") && value.endsWith("'")) {
|
|
194
|
-
return value.slice(1, -1).trim();
|
|
195
|
-
}
|
|
221
|
+
const quotedValue = parseQuotedDotEnvValue(value);
|
|
222
|
+
if (quotedValue !== null) return quotedValue;
|
|
196
223
|
|
|
197
224
|
return stripDotEnvInlineComment(value).trim();
|
|
198
225
|
}
|
|
199
226
|
|
|
200
|
-
function
|
|
201
|
-
const
|
|
227
|
+
function parseDotEnvValues(contents: string): Partial<Record<SupportedDotEnvKey, string>> {
|
|
228
|
+
const values: Partial<Record<SupportedDotEnvKey, string>> = {};
|
|
202
229
|
|
|
203
230
|
for (const line of contents.split(/\r?\n/u)) {
|
|
204
231
|
const trimmed = line.trim();
|
|
@@ -209,12 +236,12 @@ function parseDotEnvTokens(contents: string): Partial<Record<TokenEnvironmentKey
|
|
|
209
236
|
if (separatorIndex === -1) continue;
|
|
210
237
|
|
|
211
238
|
const key = assignment.slice(0, separatorIndex).trim();
|
|
212
|
-
if (!
|
|
239
|
+
if (!isSupportedDotEnvKey(key)) continue;
|
|
213
240
|
|
|
214
|
-
|
|
241
|
+
values[key] = parseDotEnvValue(assignment.slice(separatorIndex + 1).trimStart());
|
|
215
242
|
}
|
|
216
243
|
|
|
217
|
-
return
|
|
244
|
+
return values;
|
|
218
245
|
}
|
|
219
246
|
|
|
220
247
|
const MAX_DOTENV_BYTES = 64 * 1024;
|
|
@@ -223,55 +250,77 @@ function isMissingFileError(error: unknown): boolean {
|
|
|
223
250
|
return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
224
251
|
}
|
|
225
252
|
|
|
226
|
-
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
227
|
-
if (signal?.aborted) throw new Error(
|
|
253
|
+
function throwIfAborted(signal: AbortSignal | undefined, purpose: DotEnvReadPurpose): void {
|
|
254
|
+
if (signal?.aborted) throw new Error(`${purpose} .env read was aborted.`);
|
|
228
255
|
}
|
|
229
256
|
|
|
230
257
|
function errorMessage(error: unknown): string {
|
|
231
258
|
return redactSecrets(error instanceof Error ? error.message : String(error));
|
|
232
259
|
}
|
|
233
260
|
|
|
234
|
-
async function
|
|
261
|
+
async function readDotEnvValues(
|
|
235
262
|
cwd: string | undefined,
|
|
236
263
|
signal?: AbortSignal,
|
|
237
|
-
|
|
264
|
+
purpose: DotEnvReadPurpose = "GitHub token fallback",
|
|
265
|
+
): Promise<Partial<Record<SupportedDotEnvKey, string>>> {
|
|
238
266
|
if (!cwd) return {};
|
|
239
267
|
|
|
240
268
|
const envPath = join(cwd, ".env");
|
|
241
|
-
throwIfAborted(signal);
|
|
269
|
+
throwIfAborted(signal, purpose);
|
|
242
270
|
|
|
243
271
|
let stats: Awaited<ReturnType<typeof lstat>>;
|
|
244
272
|
try {
|
|
245
273
|
stats = await lstat(envPath);
|
|
246
274
|
} catch (error) {
|
|
247
275
|
if (isMissingFileError(error)) return {};
|
|
248
|
-
throw new Error(`Unable to inspect .env file for
|
|
276
|
+
throw new Error(`Unable to inspect .env file for ${purpose}: ${errorMessage(error)}`);
|
|
249
277
|
}
|
|
250
278
|
|
|
251
279
|
if (!stats.isFile()) {
|
|
252
|
-
throw new Error(
|
|
280
|
+
throw new Error(`Unable to read .env file for ${purpose}: .env must be a small regular file.`);
|
|
253
281
|
}
|
|
254
282
|
if (stats.size > MAX_DOTENV_BYTES) {
|
|
255
283
|
throw new Error(
|
|
256
|
-
`Unable to read .env file for
|
|
284
|
+
`Unable to read .env file for ${purpose}: .env is too large (${stats.size} bytes; limit ${MAX_DOTENV_BYTES} bytes).`,
|
|
257
285
|
);
|
|
258
286
|
}
|
|
259
287
|
|
|
260
|
-
throwIfAborted(signal);
|
|
288
|
+
throwIfAborted(signal, purpose);
|
|
261
289
|
|
|
262
290
|
let contents: string;
|
|
263
291
|
try {
|
|
264
292
|
contents = await readFile(envPath, { encoding: "utf8", signal });
|
|
265
293
|
} catch (error) {
|
|
266
294
|
if (isMissingFileError(error)) return {};
|
|
267
|
-
throw new Error(`Unable to read .env file for
|
|
295
|
+
throw new Error(`Unable to read .env file for ${purpose}: ${errorMessage(error)}`);
|
|
268
296
|
}
|
|
269
297
|
|
|
270
298
|
if (Buffer.byteLength(contents, "utf8") > MAX_DOTENV_BYTES) {
|
|
271
|
-
throw new Error(`Unable to read .env file for
|
|
299
|
+
throw new Error(`Unable to read .env file for ${purpose}: .env exceeded the ${MAX_DOTENV_BYTES} byte limit.`);
|
|
272
300
|
}
|
|
273
301
|
|
|
274
|
-
return
|
|
302
|
+
return parseDotEnvValues(contents);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function parsePullRequestAutofillValue(value: string): boolean {
|
|
306
|
+
const normalized = value.trim().toLowerCase();
|
|
307
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
308
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
309
|
+
throw new Error(
|
|
310
|
+
`${PULL_REQUEST_AUTOFILL_ENV_NAME} must be one of true, false, 1, 0, yes, no, on, or off.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function resolvePullRequestAutofill(
|
|
315
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
316
|
+
options: TokenResolutionOptions = {},
|
|
317
|
+
): Promise<boolean> {
|
|
318
|
+
const processValue = env[PULL_REQUEST_AUTOFILL_ENV_NAME];
|
|
319
|
+
if (processValue !== undefined) return parsePullRequestAutofillValue(processValue);
|
|
320
|
+
|
|
321
|
+
const dotEnvValues = await readDotEnvValues(options.cwd, options.signal, "pull request autofill configuration");
|
|
322
|
+
const dotEnvValue = dotEnvValues[PULL_REQUEST_AUTOFILL_ENV_NAME];
|
|
323
|
+
return dotEnvValue === undefined ? false : parsePullRequestAutofillValue(dotEnvValue);
|
|
275
324
|
}
|
|
276
325
|
|
|
277
326
|
export async function resolveGitHubToken(
|
|
@@ -281,7 +330,7 @@ export async function resolveGitHubToken(
|
|
|
281
330
|
const processToken = resolveProcessToken(env);
|
|
282
331
|
if (processToken) return processToken;
|
|
283
332
|
|
|
284
|
-
const dotEnvTokens = await
|
|
333
|
+
const dotEnvTokens = await readDotEnvValues(options.cwd, options.signal);
|
|
285
334
|
const githubToken = dotEnvTokens.GITHUB_TOKEN?.trim();
|
|
286
335
|
if (githubToken) return { token: githubToken, source: "GITHUB_TOKEN (.env)" };
|
|
287
336
|
|