@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.
@@ -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 = i === 0 ? trunk : (branches[i - 1] ?? trunk);
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 = yield* gh.getPR(branch);
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 = yield* gh.getPR(branch);
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,
@@ -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: "skipped", base: originTrunk });
113
+ results.push({ name: trunk, action: "rebased", base: originTrunk });
97
114
  if (!json) {
98
- yield* Console.error(`Would fetch and rebase ${trunk} onto ${originTrunk}`);
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 = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
105
- results.push({ name: branch, action: "skipped", base });
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
- yield* Console.error(`Would rebase and force-push ${branch} onto ${base}`);
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
- yield* Console.error(
116
- `\n${results.length} branch${results.length === 1 ? "" : "es"} would be rebased`,
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 = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
141
-
142
- // Compute old base (merge-base of this branch and its parent) before rebasing
143
- const oldBase = yield* git
144
- .mergeBase(branch, newBase)
145
- .pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase)));
146
-
147
- yield* git.checkout(branch);
148
- yield* withSpinner(
149
- `Rebasing ${branch} onto ${newBase}`,
150
- git.rebaseOnto(branch, newBase, oldBase),
151
- ).pipe(
152
- Effect.catchTag("GitError", (e) => {
153
- const hint =
154
- i === 0 ? "stacked sync" : `stacked sync --from ${branches[i - 1] ?? trunk}`;
155
- return Effect.fail(
156
- new StackError({
157
- code: ErrorCode.REBASE_CONFLICT,
158
- message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue\n ${hint}`,
159
- }),
160
- );
161
- }),
162
- );
163
- yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: true }));
164
- results.push({ name: branch, action: "rebased", base: newBase });
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(
@@ -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 handleKnownError = (e: { message: string; code?: string | undefined }) =>
59
- Console.error(e.code !== undefined ? `Error [${e.code}]: ${e.message}` : e.message).pipe(
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 = e.code !== undefined && usageCodes.has(e.code) ? 2 : 1;
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(
@@ -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) => Option.some(ref.replace(new RegExp(`^${remote}/`), ""))),
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
- const args = ["commit", "--amend"];
196
- if (options?.edit !== true) args.push("--no-edit");
197
- return run(args).pipe(Effect.asVoid);
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
  }
@@ -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
- return Option.some(target.replace(new RegExp(`^${remote}/`), ""));
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
- Effect.try({
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
- Effect.tryPromise({
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
- const forcePrefix = options?.force === true ? "+" : "";
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
- Effect.try({
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
  );