@cvr/stacked 0.4.4 → 0.6.0
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/bin/stacked +0 -0
- package/package.json +1 -1
- package/skills/stacked/SKILL.md +27 -13
- package/src/commands/adopt.ts +9 -0
- package/src/commands/amend.ts +54 -18
- package/src/commands/clean.ts +6 -4
- package/src/commands/create.ts +4 -0
- package/src/commands/delete.ts +1 -0
- package/src/commands/detect.ts +68 -9
- package/src/commands/doctor.ts +34 -5
- package/src/commands/helpers/pr-metadata.ts +12 -4
- package/src/commands/index.ts +1 -1
- package/src/commands/init.ts +19 -10
- package/src/commands/log.ts +12 -2
- package/src/commands/reorder.ts +2 -0
- package/src/commands/stacks.ts +2 -2
- package/src/commands/submit.ts +33 -6
- package/src/commands/sync.ts +130 -35
- package/src/errors/index.ts +2 -0
- package/src/main.ts +15 -5
- package/src/services/Git.ts +39 -4
- package/src/services/GitEs.ts +104 -12
- package/src/services/Stack.ts +62 -18
package/src/commands/submit.ts
CHANGED
|
@@ -90,6 +90,17 @@ export const submit = Command.make("submit", {
|
|
|
90
90
|
const trunk = yield* stacks.getTrunk();
|
|
91
91
|
const currentBranch = yield* git.currentBranch();
|
|
92
92
|
const { branches } = result.stack;
|
|
93
|
+
const data = yield* stacks.load();
|
|
94
|
+
const mergedSet = new Set(data.mergedBranches);
|
|
95
|
+
|
|
96
|
+
// Compute the effective base for a branch at index i, skipping merged branches
|
|
97
|
+
const effectiveBase = (i: number): string => {
|
|
98
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
99
|
+
const candidate = branches[j];
|
|
100
|
+
if (candidate !== undefined && !mergedSet.has(candidate)) return candidate;
|
|
101
|
+
}
|
|
102
|
+
return trunk;
|
|
103
|
+
};
|
|
93
104
|
|
|
94
105
|
const rawTitle = Option.isSome(titleOpt) ? titleOpt.value : undefined;
|
|
95
106
|
const rawBody = Option.isSome(bodyOpt) ? bodyOpt.value : undefined;
|
|
@@ -106,11 +117,13 @@ export const submit = Command.make("submit", {
|
|
|
106
117
|
|
|
107
118
|
if (titles !== undefined && titles.length !== branches.length) {
|
|
108
119
|
return yield* new StackError({
|
|
120
|
+
code: ErrorCode.USAGE_ERROR,
|
|
109
121
|
message: `--title has ${titles.length} values but stack has ${branches.length} branches`,
|
|
110
122
|
});
|
|
111
123
|
}
|
|
112
124
|
if (bodies !== undefined && bodies.length !== branches.length) {
|
|
113
125
|
return yield* new StackError({
|
|
126
|
+
code: ErrorCode.USAGE_ERROR,
|
|
114
127
|
message: `--body has ${bodies.length} values but stack has ${branches.length} branches`,
|
|
115
128
|
});
|
|
116
129
|
}
|
|
@@ -132,19 +145,34 @@ export const submit = Command.make("submit", {
|
|
|
132
145
|
const results: SubmitResult[] = [];
|
|
133
146
|
const prMap = new Map<
|
|
134
147
|
string,
|
|
135
|
-
{ number: number; url: string; state: string; body?: string | null } | null
|
|
148
|
+
{ number: number; url: string; state: string; base: string; body?: string | null } | null
|
|
136
149
|
>();
|
|
137
150
|
|
|
151
|
+
// Pre-fetch all PR statuses in parallel
|
|
152
|
+
const activeBranches = only ? branches.filter((b) => b === currentBranch) : [...branches];
|
|
153
|
+
const prResults = yield* Effect.forEach(
|
|
154
|
+
activeBranches,
|
|
155
|
+
(branch) =>
|
|
156
|
+
gh.getPR(branch).pipe(
|
|
157
|
+
Effect.map((pr) => [branch, pr] as const),
|
|
158
|
+
Effect.catchTag("GitHubError", () => Effect.succeed([branch, null] as const)),
|
|
159
|
+
),
|
|
160
|
+
{ concurrency: 5 },
|
|
161
|
+
);
|
|
162
|
+
for (const [branch, pr] of prResults) {
|
|
163
|
+
prMap.set(branch, pr);
|
|
164
|
+
}
|
|
165
|
+
|
|
138
166
|
for (let i = 0; i < branches.length; i++) {
|
|
139
167
|
const branch = branches[i];
|
|
140
168
|
if (branch === undefined) continue;
|
|
141
|
-
const base =
|
|
169
|
+
const base = effectiveBase(i);
|
|
142
170
|
|
|
143
171
|
// --only: skip branches that aren't current
|
|
144
172
|
if (only && branch !== currentBranch) continue;
|
|
145
173
|
|
|
146
174
|
if (dryRun) {
|
|
147
|
-
const existingPR =
|
|
175
|
+
const existingPR = prMap.get(branch) ?? null;
|
|
148
176
|
const action =
|
|
149
177
|
existingPR === null
|
|
150
178
|
? "would-create"
|
|
@@ -166,8 +194,7 @@ export const submit = Command.make("submit", {
|
|
|
166
194
|
|
|
167
195
|
yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: !noForce }));
|
|
168
196
|
|
|
169
|
-
const existingPR =
|
|
170
|
-
prMap.set(branch, existingPR);
|
|
197
|
+
const existingPR = prMap.get(branch) ?? null;
|
|
171
198
|
|
|
172
199
|
if (existingPR !== null) {
|
|
173
200
|
if (existingPR.base !== base) {
|
|
@@ -205,7 +232,7 @@ export const submit = Command.make("submit", {
|
|
|
205
232
|
body,
|
|
206
233
|
draft,
|
|
207
234
|
});
|
|
208
|
-
prMap.set(branch, { number: pr.number, url: pr.url, state: "OPEN" });
|
|
235
|
+
prMap.set(branch, { number: pr.number, url: pr.url, state: "OPEN", base });
|
|
209
236
|
yield* success(`Created PR #${pr.number}: ${pr.url}`);
|
|
210
237
|
results.push({
|
|
211
238
|
branch,
|
package/src/commands/sync.ts
CHANGED
|
@@ -21,10 +21,13 @@ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"
|
|
|
21
21
|
const dryRunFlag = Flag.boolean("dry-run").pipe(
|
|
22
22
|
Flag.withDescription("Show rebase plan without executing"),
|
|
23
23
|
);
|
|
24
|
+
const rebaseOnlyFlag = Flag.boolean("rebase-only").pipe(
|
|
25
|
+
Flag.withDescription("Force rebase path (skip tree-merge)"),
|
|
26
|
+
);
|
|
24
27
|
|
|
25
28
|
interface SyncResult {
|
|
26
29
|
name: string;
|
|
27
|
-
action: "rebased" | "skipped" | "up-to-date";
|
|
30
|
+
action: "rebased" | "merged" | "skipped" | "up-to-date";
|
|
28
31
|
base: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -33,6 +36,7 @@ export const sync = Command.make("sync", {
|
|
|
33
36
|
from: fromFlag,
|
|
34
37
|
json: jsonFlag,
|
|
35
38
|
dryRun: dryRunFlag,
|
|
39
|
+
rebaseOnly: rebaseOnlyFlag,
|
|
36
40
|
}).pipe(
|
|
37
41
|
Command.withDescription(
|
|
38
42
|
"Fetch, rebase, and force-push stack branches. Use --from to start from a branch.",
|
|
@@ -41,8 +45,9 @@ export const sync = Command.make("sync", {
|
|
|
41
45
|
{ command: "stacked sync", description: "Sync local trunk, then rebase entire stack on trunk" },
|
|
42
46
|
{ command: "stacked sync --from feat-auth", description: "Resume from a specific branch" },
|
|
43
47
|
{ command: "stacked sync --dry-run", description: "Preview rebase plan" },
|
|
48
|
+
{ command: "stacked sync --rebase-only", description: "Force rebase (skip tree-merge)" },
|
|
44
49
|
]),
|
|
45
|
-
Command.withHandler(({ trunk: trunkOpt, from: fromOpt, json, dryRun }) =>
|
|
50
|
+
Command.withHandler(({ trunk: trunkOpt, from: fromOpt, json, dryRun, rebaseOnly }) =>
|
|
46
51
|
Effect.gen(function* () {
|
|
47
52
|
const git = yield* GitService;
|
|
48
53
|
const gh = yield* GitHubService;
|
|
@@ -72,6 +77,18 @@ export const sync = Command.make("sync", {
|
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
const { branches } = result.stack;
|
|
80
|
+
const data = yield* stacks.load();
|
|
81
|
+
const mergedSet = new Set(data.mergedBranches);
|
|
82
|
+
|
|
83
|
+
// Compute the effective base for a branch at index i, skipping merged branches
|
|
84
|
+
const effectiveBase = (i: number, fallback: string): string => {
|
|
85
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
86
|
+
const candidate = branches[j];
|
|
87
|
+
if (candidate !== undefined && !mergedSet.has(candidate)) return candidate;
|
|
88
|
+
}
|
|
89
|
+
return fallback;
|
|
90
|
+
};
|
|
91
|
+
|
|
75
92
|
const fromBranch = Option.isSome(fromOpt) ? fromOpt.value : undefined;
|
|
76
93
|
|
|
77
94
|
let startIdx = 0;
|
|
@@ -93,18 +110,43 @@ export const sync = Command.make("sync", {
|
|
|
93
110
|
const results: SyncResult[] = [];
|
|
94
111
|
|
|
95
112
|
if (dryRun) {
|
|
96
|
-
results.push({ name: trunk, action: "
|
|
113
|
+
results.push({ name: trunk, action: "rebased", base: originTrunk });
|
|
97
114
|
if (!json) {
|
|
98
|
-
yield* Console.error(
|
|
115
|
+
yield* Console.error(`${trunk}: rebase onto ${originTrunk}`);
|
|
99
116
|
}
|
|
100
117
|
|
|
101
118
|
for (let i = startIdx; i < branches.length; i++) {
|
|
102
119
|
const branch = branches[i];
|
|
103
120
|
if (branch === undefined) continue;
|
|
104
|
-
const base =
|
|
105
|
-
|
|
121
|
+
const base = effectiveBase(i, originTrunk);
|
|
122
|
+
|
|
123
|
+
const newBaseTip = yield* git.revParse(base);
|
|
124
|
+
const branchHead = yield* git.revParse(branch);
|
|
125
|
+
const syncedOnto = yield* stacks.getSyncedOnto(branch);
|
|
126
|
+
|
|
127
|
+
let action: SyncResult["action"];
|
|
128
|
+
if (syncedOnto !== null && syncedOnto === newBaseTip) {
|
|
129
|
+
action = "up-to-date";
|
|
130
|
+
} else {
|
|
131
|
+
const alreadyIncorporated = yield* git
|
|
132
|
+
.isAncestor(newBaseTip, branchHead)
|
|
133
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
134
|
+
if (alreadyIncorporated) {
|
|
135
|
+
action = "up-to-date";
|
|
136
|
+
} else {
|
|
137
|
+
action = !rebaseOnly && git.supportsTreeMerge() ? "merged" : "rebased";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
results.push({ name: branch, action, base });
|
|
106
142
|
if (!json) {
|
|
107
|
-
|
|
143
|
+
const verb =
|
|
144
|
+
action === "up-to-date"
|
|
145
|
+
? "up-to-date"
|
|
146
|
+
: action === "merged"
|
|
147
|
+
? `tree-merge onto ${base}`
|
|
148
|
+
: `rebase onto ${base}`;
|
|
149
|
+
yield* Console.error(`${branch}: ${verb}`);
|
|
108
150
|
}
|
|
109
151
|
}
|
|
110
152
|
|
|
@@ -112,9 +154,12 @@ export const sync = Command.make("sync", {
|
|
|
112
154
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
113
155
|
yield* Console.log(JSON.stringify({ branches: results }, null, 2));
|
|
114
156
|
} else {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
157
|
+
const changed = results.filter((r) => r.action !== "up-to-date").length;
|
|
158
|
+
const skipped = results.filter((r) => r.action === "up-to-date").length;
|
|
159
|
+
const parts: string[] = [];
|
|
160
|
+
if (changed > 0) parts.push(`${changed} to sync`);
|
|
161
|
+
if (skipped > 0) parts.push(`${skipped} up-to-date`);
|
|
162
|
+
yield* Console.error(`\n${parts.join(", ")}`);
|
|
118
163
|
}
|
|
119
164
|
return;
|
|
120
165
|
}
|
|
@@ -137,31 +182,81 @@ export const sync = Command.make("sync", {
|
|
|
137
182
|
for (let i = startIdx; i < branches.length; i++) {
|
|
138
183
|
const branch = branches[i];
|
|
139
184
|
if (branch === undefined) continue;
|
|
140
|
-
const newBase =
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
185
|
+
const newBase = effectiveBase(i, originTrunk);
|
|
186
|
+
|
|
187
|
+
const newBaseTip = yield* git.revParse(newBase);
|
|
188
|
+
const branchHead = yield* git.revParse(branch);
|
|
189
|
+
const syncedOnto = yield* stacks.getSyncedOnto(branch);
|
|
190
|
+
|
|
191
|
+
// Skip if parent hasn't moved since last sync
|
|
192
|
+
if (syncedOnto !== null && syncedOnto === newBaseTip) {
|
|
193
|
+
results.push({ name: branch, action: "up-to-date", base: newBase });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Skip if parent is already an ancestor of branch (manually synced)
|
|
198
|
+
const alreadyIncorporated = yield* git
|
|
199
|
+
.isAncestor(newBaseTip, branchHead)
|
|
200
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
201
|
+
if (alreadyIncorporated) {
|
|
202
|
+
yield* stacks.updateSyncedOnto(branch, newBaseTip);
|
|
203
|
+
results.push({ name: branch, action: "up-to-date", base: newBase });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Resolve old base: prefer recorded fork-point, fall back to merge-base
|
|
208
|
+
const oldBase =
|
|
209
|
+
syncedOnto ??
|
|
210
|
+
(yield* git
|
|
211
|
+
.mergeBase(branch, newBase)
|
|
212
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase))));
|
|
213
|
+
|
|
214
|
+
// Try tree-merge fast path first (unless --rebase-only)
|
|
215
|
+
const mergeResult = rebaseOnly
|
|
216
|
+
? ({ action: "conflict" } as const)
|
|
217
|
+
: yield* git
|
|
218
|
+
.treeMergeSync({
|
|
219
|
+
branch,
|
|
220
|
+
branchHead,
|
|
221
|
+
oldBase,
|
|
222
|
+
newBase: newBaseTip,
|
|
223
|
+
message: `sync: incorporate changes from ${newBase}`,
|
|
224
|
+
})
|
|
225
|
+
.pipe(
|
|
226
|
+
Effect.catchTag("GitError", () =>
|
|
227
|
+
Effect.succeed({ action: "conflict" as const }),
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
if (mergeResult.action === "merged") {
|
|
232
|
+
yield* stacks.updateSyncedOnto(branch, newBaseTip);
|
|
233
|
+
yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: true }));
|
|
234
|
+
results.push({ name: branch, action: "merged", base: newBase });
|
|
235
|
+
} else if (mergeResult.action === "up-to-date") {
|
|
236
|
+
yield* stacks.updateSyncedOnto(branch, newBaseTip);
|
|
237
|
+
results.push({ name: branch, action: "up-to-date", base: newBase });
|
|
238
|
+
} else {
|
|
239
|
+
// Conflict or unsupported backend — fall back to rebase with corrected oldBase
|
|
240
|
+
yield* git.checkout(branch);
|
|
241
|
+
yield* withSpinner(
|
|
242
|
+
`Rebasing ${branch} onto ${newBase}`,
|
|
243
|
+
git.rebaseOnto(branch, newBase, oldBase),
|
|
244
|
+
).pipe(
|
|
245
|
+
Effect.catchTag("GitError", (e) => {
|
|
246
|
+
const hint =
|
|
247
|
+
i === 0 ? "stacked sync" : `stacked sync --from ${branches[i - 1] ?? trunk}`;
|
|
248
|
+
return Effect.fail(
|
|
249
|
+
new StackError({
|
|
250
|
+
code: ErrorCode.REBASE_CONFLICT,
|
|
251
|
+
message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue\n ${hint}`,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
yield* stacks.updateSyncedOnto(branch, newBaseTip);
|
|
257
|
+
yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: true }));
|
|
258
|
+
results.push({ name: branch, action: "rebased", base: newBase });
|
|
259
|
+
}
|
|
165
260
|
}
|
|
166
261
|
}).pipe(
|
|
167
262
|
Effect.ensuring(
|
package/src/errors/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const ErrorCode = {
|
|
|
19
19
|
STACK_EMPTY: "STACK_EMPTY",
|
|
20
20
|
TRUNK_ERROR: "TRUNK_ERROR",
|
|
21
21
|
STACK_EXISTS: "STACK_EXISTS",
|
|
22
|
+
USAGE_ERROR: "USAGE_ERROR",
|
|
23
|
+
HAS_CHILDREN: "HAS_CHILDREN",
|
|
22
24
|
} as const;
|
|
23
25
|
|
|
24
26
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
package/src/main.ts
CHANGED
|
@@ -15,11 +15,11 @@ const version = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
|
|
|
15
15
|
// Global Flags (parsed before CLI framework, stripped from argv)
|
|
16
16
|
// ============================================================================
|
|
17
17
|
|
|
18
|
-
const globalFlags = new Set(["--verbose", "--quiet", "-q", "--no-color", "--yes", "-y"]);
|
|
18
|
+
const globalFlags = new Set(["--verbose", "-v", "--quiet", "-q", "--no-color", "--yes", "-y"]);
|
|
19
19
|
const flagArgs = new Set(process.argv.filter((a) => globalFlags.has(a)));
|
|
20
20
|
process.argv = process.argv.filter((a) => !globalFlags.has(a));
|
|
21
21
|
|
|
22
|
-
const isVerbose = flagArgs.has("--verbose");
|
|
22
|
+
const isVerbose = flagArgs.has("--verbose") || flagArgs.has("-v");
|
|
23
23
|
const isQuiet = flagArgs.has("--quiet") || flagArgs.has("-q");
|
|
24
24
|
const isNoColor = flagArgs.has("--no-color");
|
|
25
25
|
const isYes = flagArgs.has("--yes") || flagArgs.has("-y");
|
|
@@ -53,16 +53,26 @@ const usageCodes = new Set([
|
|
|
53
53
|
"ALREADY_AT_BOTTOM",
|
|
54
54
|
"TRUNK_ERROR",
|
|
55
55
|
"STACK_EXISTS",
|
|
56
|
+
"USAGE_ERROR",
|
|
57
|
+
"HAS_CHILDREN",
|
|
56
58
|
]);
|
|
57
59
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
+
const isJson = process.argv.includes("--json");
|
|
61
|
+
|
|
62
|
+
const handleKnownError = (e: { message: string; code?: string | undefined }) => {
|
|
63
|
+
const exitCode = e.code !== undefined && usageCodes.has(e.code) ? 2 : 1;
|
|
64
|
+
const errorOutput = isJson
|
|
65
|
+
? Console.log(JSON.stringify({ error: { code: e.code ?? null, message: e.message } }))
|
|
66
|
+
: Console.error(e.code !== undefined ? `Error [${e.code}]: ${e.message}` : e.message);
|
|
67
|
+
|
|
68
|
+
return errorOutput.pipe(
|
|
60
69
|
Effect.andThen(
|
|
61
70
|
Effect.sync(() => {
|
|
62
|
-
process.exitCode =
|
|
71
|
+
process.exitCode = exitCode;
|
|
63
72
|
}),
|
|
64
73
|
),
|
|
65
74
|
);
|
|
75
|
+
};
|
|
66
76
|
|
|
67
77
|
// @effect-diagnostics-next-line effect/strictEffectProvide:off
|
|
68
78
|
BunRuntime.runMain(
|
package/src/services/Git.ts
CHANGED
|
@@ -37,6 +37,14 @@ export class GitService extends ServiceMap.Service<
|
|
|
37
37
|
readonly commitAmend: (options?: { edit?: boolean }) => Effect.Effect<void, GitError>;
|
|
38
38
|
readonly fetch: (remote?: string) => Effect.Effect<void, GitError>;
|
|
39
39
|
readonly deleteRemoteBranch: (branch: string) => Effect.Effect<void, GitError>;
|
|
40
|
+
readonly treeMergeSync: (opts: {
|
|
41
|
+
branch: string;
|
|
42
|
+
branchHead: string;
|
|
43
|
+
oldBase: string;
|
|
44
|
+
newBase: string;
|
|
45
|
+
message: string;
|
|
46
|
+
}) => Effect.Effect<{ action: "merged" | "up-to-date" | "conflict" }, GitError>;
|
|
47
|
+
readonly supportsTreeMerge: () => boolean;
|
|
40
48
|
}
|
|
41
49
|
>()("@cvr/stacked/services/Git/GitService") {
|
|
42
50
|
static layer: Layer.Layer<GitService> = Layer.sync(GitService, () => {
|
|
@@ -121,7 +129,10 @@ export class GitService extends ServiceMap.Service<
|
|
|
121
129
|
|
|
122
130
|
remoteDefaultBranch: (remote = "origin") =>
|
|
123
131
|
run(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`]).pipe(
|
|
124
|
-
Effect.map((ref) =>
|
|
132
|
+
Effect.map((ref) => {
|
|
133
|
+
const prefix = `${remote}/`;
|
|
134
|
+
return Option.some(ref.startsWith(prefix) ? ref.slice(prefix.length) : ref);
|
|
135
|
+
}),
|
|
125
136
|
Effect.catchTag("GitError", () => Effect.succeed(Option.none())),
|
|
126
137
|
),
|
|
127
138
|
|
|
@@ -192,15 +203,37 @@ export class GitService extends ServiceMap.Service<
|
|
|
192
203
|
),
|
|
193
204
|
|
|
194
205
|
commitAmend: (options) => {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
if (options?.edit === true) {
|
|
207
|
+
// Interactive editor needs inherited stdio, not piped
|
|
208
|
+
return Effect.tryPromise({
|
|
209
|
+
try: async () => {
|
|
210
|
+
const proc = Bun.spawn(["git", "commit", "--amend"], {
|
|
211
|
+
stdin: "inherit",
|
|
212
|
+
stdout: "inherit",
|
|
213
|
+
stderr: "inherit",
|
|
214
|
+
});
|
|
215
|
+
const exitCode = await proc.exited;
|
|
216
|
+
if (exitCode !== 0) {
|
|
217
|
+
throw new Error(`git commit --amend failed with exit code ${exitCode}`);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
catch: (e) =>
|
|
221
|
+
new GitError({
|
|
222
|
+
message: `Process failed: ${e}`,
|
|
223
|
+
command: "git commit --amend",
|
|
224
|
+
}),
|
|
225
|
+
}).pipe(Effect.asVoid);
|
|
226
|
+
}
|
|
227
|
+
return run(["commit", "--amend", "--no-edit"]).pipe(Effect.asVoid);
|
|
198
228
|
},
|
|
199
229
|
|
|
200
230
|
fetch: (remote) => run(["fetch", remote ?? "origin"]).pipe(Effect.asVoid),
|
|
201
231
|
|
|
202
232
|
deleteRemoteBranch: (branch) =>
|
|
203
233
|
run(["push", "origin", "--delete", branch]).pipe(Effect.asVoid),
|
|
234
|
+
|
|
235
|
+
treeMergeSync: () => Effect.succeed({ action: "conflict" as const }),
|
|
236
|
+
supportsTreeMerge: () => false,
|
|
204
237
|
};
|
|
205
238
|
});
|
|
206
239
|
|
|
@@ -227,6 +260,8 @@ export class GitService extends ServiceMap.Service<
|
|
|
227
260
|
commitAmend: () => Effect.void,
|
|
228
261
|
fetch: () => Effect.void,
|
|
229
262
|
deleteRemoteBranch: () => Effect.void,
|
|
263
|
+
treeMergeSync: () => Effect.succeed({ action: "conflict" as const }),
|
|
264
|
+
supportsTreeMerge: () => false,
|
|
230
265
|
...impl,
|
|
231
266
|
});
|
|
232
267
|
}
|
package/src/services/GitEs.ts
CHANGED
|
@@ -111,7 +111,8 @@ export const GitEsLayer = Layer.effect(
|
|
|
111
111
|
const ref = repo.findReference(`refs/remotes/${remote}/HEAD`);
|
|
112
112
|
if (ref === null) return Option.none();
|
|
113
113
|
const target = ref.resolve().shorthand();
|
|
114
|
-
|
|
114
|
+
const prefix = `${remote}/`;
|
|
115
|
+
return Option.some(target.startsWith(prefix) ? target.slice(prefix.length) : target);
|
|
115
116
|
},
|
|
116
117
|
catch: (error) => makeGitError(`es-git.remoteDefaultBranch ${remote}`, error),
|
|
117
118
|
}).pipe(Effect.catchTag("GitError", () => Effect.succeed(Option.none()))),
|
|
@@ -127,13 +128,32 @@ export const GitEsLayer = Layer.effect(
|
|
|
127
128
|
catch: (error) => makeGitError(`es-git.createBranch ${name}`, error),
|
|
128
129
|
}).pipe(Effect.asVoid),
|
|
129
130
|
|
|
130
|
-
deleteBranch: (name) =>
|
|
131
|
-
|
|
131
|
+
deleteBranch: (name, force) => {
|
|
132
|
+
// When force is false/undefined, shell out to `git branch -d` so git
|
|
133
|
+
// enforces "must be fully merged" check. es-git has no equivalent.
|
|
134
|
+
if (force !== true) {
|
|
135
|
+
return Effect.tryPromise({
|
|
136
|
+
try: async () => {
|
|
137
|
+
const proc = Bun.spawn(["git", "branch", "-d", "--", name], {
|
|
138
|
+
stdout: "pipe",
|
|
139
|
+
stderr: "pipe",
|
|
140
|
+
});
|
|
141
|
+
const exitCode = await proc.exited;
|
|
142
|
+
if (exitCode !== 0) {
|
|
143
|
+
const stderr = await new Response(proc.stderr).text();
|
|
144
|
+
throw new Error(stderr.trim() || `git branch -d failed with exit code ${exitCode}`);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
catch: (error) => makeGitError(`es-git.deleteBranch ${name}`, error),
|
|
148
|
+
}).pipe(Effect.asVoid);
|
|
149
|
+
}
|
|
150
|
+
return Effect.try({
|
|
132
151
|
try: () => {
|
|
133
152
|
repo.getBranch(name, "Local").delete();
|
|
134
153
|
},
|
|
135
154
|
catch: (error) => makeGitError(`es-git.deleteBranch ${name}`, error),
|
|
136
|
-
}).pipe(Effect.asVoid)
|
|
155
|
+
}).pipe(Effect.asVoid);
|
|
156
|
+
},
|
|
137
157
|
|
|
138
158
|
checkout: (name) =>
|
|
139
159
|
Effect.try({
|
|
@@ -177,17 +197,34 @@ export const GitEsLayer = Layer.effect(
|
|
|
177
197
|
catch: (error) => makeGitError("es-git.rebaseAbort", error),
|
|
178
198
|
}).pipe(Effect.asVoid),
|
|
179
199
|
|
|
180
|
-
push: (branch, options) =>
|
|
181
|
-
|
|
200
|
+
push: (branch, options) => {
|
|
201
|
+
// es-git's protocol has no --force-with-lease equivalent; shell out to git CLI
|
|
202
|
+
if (options?.force === true) {
|
|
203
|
+
return Effect.tryPromise({
|
|
204
|
+
try: async () => {
|
|
205
|
+
const proc = Bun.spawn(
|
|
206
|
+
["git", "push", "--force-with-lease", "-u", "origin", branch],
|
|
207
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
208
|
+
);
|
|
209
|
+
const exitCode = await proc.exited;
|
|
210
|
+
if (exitCode !== 0) {
|
|
211
|
+
const stderr = await new Response(proc.stderr).text();
|
|
212
|
+
throw new Error(stderr.trim() || `git push failed with exit code ${exitCode}`);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
catch: (error) => makeGitError(`es-git.push ${branch}`, error),
|
|
216
|
+
}).pipe(Effect.asVoid);
|
|
217
|
+
}
|
|
218
|
+
return Effect.tryPromise({
|
|
182
219
|
try: async () => {
|
|
183
220
|
const remote = repo.getRemote("origin");
|
|
184
|
-
|
|
185
|
-
await remote.push([`${forcePrefix}refs/heads/${branch}:refs/heads/${branch}`]);
|
|
221
|
+
await remote.push([`refs/heads/${branch}:refs/heads/${branch}`]);
|
|
186
222
|
const localBranch = repo.findBranch(branch, "Local");
|
|
187
223
|
localBranch?.setUpstream(`origin/${branch}`);
|
|
188
224
|
},
|
|
189
225
|
catch: (error) => makeGitError(`es-git.push ${branch}`, error),
|
|
190
|
-
}).pipe(Effect.asVoid)
|
|
226
|
+
}).pipe(Effect.asVoid);
|
|
227
|
+
},
|
|
191
228
|
|
|
192
229
|
log: (branch, options) =>
|
|
193
230
|
Effect.try({
|
|
@@ -274,8 +311,25 @@ export const GitEsLayer = Layer.effect(
|
|
|
274
311
|
catch: (error) => makeGitError("es-git.isRebaseInProgress", error),
|
|
275
312
|
}).pipe(Effect.catchTag("GitError", () => Effect.succeed(false))),
|
|
276
313
|
|
|
277
|
-
commitAmend: () =>
|
|
278
|
-
|
|
314
|
+
commitAmend: (options) => {
|
|
315
|
+
// --edit requires an interactive editor; shell out to git CLI
|
|
316
|
+
if (options?.edit === true) {
|
|
317
|
+
return Effect.tryPromise({
|
|
318
|
+
try: async () => {
|
|
319
|
+
const proc = Bun.spawn(["git", "commit", "--amend"], {
|
|
320
|
+
stdin: "inherit",
|
|
321
|
+
stdout: "inherit",
|
|
322
|
+
stderr: "inherit",
|
|
323
|
+
});
|
|
324
|
+
const exitCode = await proc.exited;
|
|
325
|
+
if (exitCode !== 0) {
|
|
326
|
+
throw new Error(`git commit --amend failed with exit code ${exitCode}`);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
catch: (error) => makeGitError("es-git.commitAmend", error),
|
|
330
|
+
}).pipe(Effect.asVoid);
|
|
331
|
+
}
|
|
332
|
+
return Effect.try({
|
|
279
333
|
try: () => {
|
|
280
334
|
const headOid = repo.head().target();
|
|
281
335
|
if (headOid === null) {
|
|
@@ -287,7 +341,8 @@ export const GitEsLayer = Layer.effect(
|
|
|
287
341
|
});
|
|
288
342
|
},
|
|
289
343
|
catch: (error) => makeGitError("es-git.commitAmend", error),
|
|
290
|
-
}).pipe(Effect.asVoid)
|
|
344
|
+
}).pipe(Effect.asVoid);
|
|
345
|
+
},
|
|
291
346
|
|
|
292
347
|
fetch: (remote = "origin") =>
|
|
293
348
|
Effect.tryPromise({
|
|
@@ -304,6 +359,43 @@ export const GitEsLayer = Layer.effect(
|
|
|
304
359
|
},
|
|
305
360
|
catch: (error) => makeGitError(`es-git.deleteRemoteBranch ${branch}`, error),
|
|
306
361
|
}).pipe(Effect.asVoid),
|
|
362
|
+
|
|
363
|
+
treeMergeSync: ({ branch, oldBase, newBase }) =>
|
|
364
|
+
Effect.try({
|
|
365
|
+
try: () => {
|
|
366
|
+
const oldBaseOid = resolveOid(repo, oldBase);
|
|
367
|
+
const newBaseOid = resolveOid(repo, newBase);
|
|
368
|
+
const branchRef = refName(branch);
|
|
369
|
+
|
|
370
|
+
// Pre-flight: check for conflicts via mergeTrees
|
|
371
|
+
const oldBaseTree = repo.getCommit(oldBaseOid).tree();
|
|
372
|
+
const newBaseTree = repo.getCommit(newBaseOid).tree();
|
|
373
|
+
const branchHeadOid = repo.getReference(branchRef).resolve().target();
|
|
374
|
+
if (branchHeadOid === null) {
|
|
375
|
+
return { action: "conflict" as const };
|
|
376
|
+
}
|
|
377
|
+
const branchTree = repo.getCommit(branchHeadOid).tree();
|
|
378
|
+
|
|
379
|
+
const index = repo.mergeTrees(oldBaseTree, newBaseTree, branchTree);
|
|
380
|
+
if (index.hasConflicts()) {
|
|
381
|
+
return { action: "conflict" as const };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Check if anything actually changed
|
|
385
|
+
if (newBaseOid === oldBaseOid) {
|
|
386
|
+
return { action: "up-to-date" as const };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Clean merge — execute via rebase (which handles tree writing properly)
|
|
390
|
+
performRebase(repo, branchRef, oldBaseOid, newBaseOid);
|
|
391
|
+
|
|
392
|
+
return { action: "merged" as const };
|
|
393
|
+
},
|
|
394
|
+
catch: (error) =>
|
|
395
|
+
makeGitError(`es-git.treeMergeSync ${branch} ${oldBase} ${newBase}`, error),
|
|
396
|
+
}),
|
|
397
|
+
|
|
398
|
+
supportsTreeMerge: () => true,
|
|
307
399
|
};
|
|
308
400
|
}),
|
|
309
401
|
);
|