@cvr/stacked 0.5.0 → 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/create.ts +4 -0
- package/src/commands/doctor.ts +28 -1
- package/src/commands/sync.ts +115 -32
- package/src/services/Git.ts +13 -0
- package/src/services/GitEs.ts +37 -0
- package/src/services/Stack.ts +51 -3
package/bin/stacked
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/skills/stacked/SKILL.md
CHANGED
|
@@ -45,7 +45,7 @@ What do you need?
|
|
|
45
45
|
| `stacked down` | Move down one branch in the stack |
|
|
46
46
|
| `stacked top` | Jump to top of stack |
|
|
47
47
|
| `stacked bottom` | Jump to bottom of stack |
|
|
48
|
-
| `stacked sync` | Fetch +
|
|
48
|
+
| `stacked sync` | Fetch + sync stack on trunk (`--from`, `--dry-run`, `--rebase-only`, `--json`) |
|
|
49
49
|
| `stacked detect` | Detect branch chains and register as stacks (`--dry-run`, `--json`) |
|
|
50
50
|
| `stacked clean` | Remove merged branches + remote branches (`--dry-run`, `--json`) |
|
|
51
51
|
| `stacked delete <name>` | Remove branch from stack + git + remote (`--keep-remote`, `--force`, `--dry-run`, `--json`) |
|
|
@@ -143,23 +143,33 @@ All navigation commands support `--json` for structured output. `checkout` falls
|
|
|
143
143
|
|
|
144
144
|
## Syncing / Rebasing / Pushing
|
|
145
145
|
|
|
146
|
-
Fetch latest trunk,
|
|
146
|
+
Fetch latest trunk, then incrementally sync the stack bottom-to-top using fork-point tracking:
|
|
147
147
|
|
|
148
148
|
```sh
|
|
149
149
|
stacked sync
|
|
150
|
-
stacked sync --dry-run
|
|
151
|
-
stacked sync --
|
|
150
|
+
stacked sync --dry-run # preview sync plan with predicted actions per branch
|
|
151
|
+
stacked sync --rebase-only # force rebase path (skip tree-merge)
|
|
152
|
+
stacked sync --json # structured output: { branches: [{ name, action, base }] }
|
|
152
153
|
```
|
|
153
154
|
|
|
154
|
-
|
|
155
|
+
**How sync works:**
|
|
156
|
+
|
|
157
|
+
1. Fetch and rebase trunk onto `origin/trunk`
|
|
158
|
+
2. For each branch, check if parent moved since last sync (`syncedOnto` metadata)
|
|
159
|
+
3. If parent unchanged → skip (no push needed)
|
|
160
|
+
4. If parent changed → try tree-merge fast path (es-git: `mergeTrees`, creates merge commit, no replay)
|
|
161
|
+
5. If tree-merge conflicts → fall back to rebase with corrected fork-point as `oldBase`
|
|
162
|
+
6. Force-push changed branches, update `syncedOnto` metadata
|
|
163
|
+
|
|
164
|
+
After mid-stack changes, sync only the branches above a specific point:
|
|
155
165
|
|
|
156
166
|
```sh
|
|
157
167
|
stacked checkout feat-auth
|
|
158
168
|
# ... make changes, commit ...
|
|
159
|
-
stacked sync --from feat-auth #
|
|
169
|
+
stacked sync --from feat-auth # syncs only children of feat-auth
|
|
160
170
|
```
|
|
161
171
|
|
|
162
|
-
**Note:** `sync` requires a clean working tree — commit or stash before running (except with `--dry-run`). On rebase conflict, the rebase is left in progress so you can resolve it:
|
|
172
|
+
**Note:** `sync` requires a clean working tree — commit or stash before running (except with `--dry-run`). On rebase conflict (fallback path), the rebase is left in progress so you can resolve it:
|
|
163
173
|
|
|
164
174
|
```sh
|
|
165
175
|
# On conflict:
|
|
@@ -311,10 +321,11 @@ Usage errors (invalid args, bad state) exit code 2. Operational errors (git/gh f
|
|
|
311
321
|
|
|
312
322
|
## Data Model
|
|
313
323
|
|
|
314
|
-
Stack metadata lives in `.git/stacked.json`. Each branch
|
|
324
|
+
Stack metadata lives in `.git/stacked.json`. Each branch record stores:
|
|
315
325
|
|
|
316
|
-
- `
|
|
317
|
-
- `
|
|
326
|
+
- `stack` — which stack it belongs to
|
|
327
|
+
- `parent` — parent branch name (null for root)
|
|
328
|
+
- `syncedOnto` — (optional) the parent branch tip SHA at last sync, used for incremental sync
|
|
318
329
|
|
|
319
330
|
Trunk is auto-detected on first use from `origin/HEAD` when available, then falls back to local `main`, `master`, or `develop`. Override with `stacked trunk <name>`.
|
|
320
331
|
|
|
@@ -356,8 +367,10 @@ stacked down # go to previous branch
|
|
|
356
367
|
## Gotchas
|
|
357
368
|
|
|
358
369
|
- `stacked sync` requires a clean working tree — commit or stash first (except `--dry-run`)
|
|
359
|
-
- `stacked sync`
|
|
360
|
-
- `stacked sync`
|
|
370
|
+
- `stacked sync` uses tree-merge by default (es-git); falls back to rebase on conflict or CLI backend
|
|
371
|
+
- `stacked sync` skips branches whose parent hasn't moved since last sync
|
|
372
|
+
- `stacked sync` leaves rebase in progress on conflict (fallback path) — resolve with `git rebase --continue`, then resume with `stacked sync --from <parent>`
|
|
373
|
+
- `syncedOnto` metadata may become stale if branches are rebased outside `stacked` — first sync after that falls back to `merge-base`
|
|
361
374
|
- `stacked submit` force-pushes by default (use `--no-force` to disable)
|
|
362
375
|
- `stacked submit` and `stacked clean` require `gh` CLI authenticated (`gh auth login`)
|
|
363
376
|
- PRs target parent branches, not trunk — this is intentional for stacked review
|
|
@@ -369,4 +382,5 @@ stacked down # go to previous branch
|
|
|
369
382
|
- Detached HEAD is detected and produces a clear error — checkout a branch first
|
|
370
383
|
- Stack file writes are atomic (write to tmp, then rename) to prevent corruption
|
|
371
384
|
- `create` and `adopt` are idempotent — safe to re-run after transient failures
|
|
372
|
-
- Use `stacked doctor` to detect and fix metadata drift (stale branches, missing trunk)
|
|
385
|
+
- Use `stacked doctor` to detect and fix metadata drift (stale branches, missing trunk, stale syncedOnto)
|
|
386
|
+
- `stacked doctor --fix` clears stale `syncedOnto` entries pointing at non-existent commits
|
package/src/commands/adopt.ts
CHANGED
|
@@ -87,6 +87,15 @@ export const adopt = Command.make("adopt", {
|
|
|
87
87
|
yield* stacks.addBranch(result.name, branch, afterBranch);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// Record fork-point for incremental sync based on the branch's merge-base with its parent
|
|
91
|
+
const parentRef = Option.isSome(after) ? after.value : yield* git.currentBranch();
|
|
92
|
+
const parentTip = yield* git
|
|
93
|
+
.mergeBase(branch, parentRef)
|
|
94
|
+
.pipe(Effect.catchTag("GitError", () => git.revParse(parentRef)));
|
|
95
|
+
yield* stacks
|
|
96
|
+
.updateSyncedOnto(branch, parentTip)
|
|
97
|
+
.pipe(Effect.catchTag("StackError", () => Effect.void));
|
|
98
|
+
|
|
90
99
|
if (json) {
|
|
91
100
|
const stackResult = yield* stacks.currentStack();
|
|
92
101
|
const stackName = stackResult?.name ?? branch;
|
package/src/commands/amend.ts
CHANGED
|
@@ -61,34 +61,70 @@ export const amend = Command.make("amend", {
|
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
//
|
|
64
|
+
// Sync children using fork-point-aware algorithm
|
|
65
65
|
const children = branches.slice(idx + 1);
|
|
66
66
|
const synced: string[] = [];
|
|
67
|
+
const data = yield* stacks.load();
|
|
68
|
+
const mergedSet = new Set(data.mergedBranches);
|
|
67
69
|
|
|
68
70
|
yield* Effect.gen(function* () {
|
|
69
71
|
for (let i = 0; i < children.length; i++) {
|
|
70
72
|
const branch = children[i];
|
|
71
73
|
if (branch === undefined) continue;
|
|
72
|
-
|
|
74
|
+
// Compute effective base, skipping merged branches (same as sync)
|
|
75
|
+
let newBase = fromBranch;
|
|
76
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
77
|
+
const candidate = children[j];
|
|
78
|
+
if (candidate !== undefined && !mergedSet.has(candidate)) {
|
|
79
|
+
newBase = candidate;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
73
83
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
const newBaseTip = yield* git.revParse(newBase);
|
|
85
|
+
const branchHead = yield* git.revParse(branch);
|
|
86
|
+
const syncedOnto = yield* stacks.getSyncedOnto(branch);
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
// Resolve old base: prefer recorded fork-point, fall back to merge-base
|
|
89
|
+
const oldBase =
|
|
90
|
+
syncedOnto ??
|
|
91
|
+
(yield* git
|
|
92
|
+
.mergeBase(branch, newBase)
|
|
93
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase))));
|
|
94
|
+
|
|
95
|
+
// Try tree-merge fast path
|
|
96
|
+
const mergeResult = yield* git
|
|
97
|
+
.treeMergeSync({
|
|
98
|
+
branch,
|
|
99
|
+
branchHead,
|
|
100
|
+
oldBase,
|
|
101
|
+
newBase: newBaseTip,
|
|
102
|
+
message: `sync: incorporate changes from ${newBase}`,
|
|
103
|
+
})
|
|
104
|
+
.pipe(
|
|
105
|
+
Effect.catchTag("GitError", () => Effect.succeed({ action: "conflict" as const })),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (mergeResult.action === "merged" || mergeResult.action === "up-to-date") {
|
|
109
|
+
yield* stacks.updateSyncedOnto(branch, newBaseTip);
|
|
110
|
+
} else {
|
|
111
|
+
// Conflict — fall back to rebase with corrected oldBase
|
|
112
|
+
yield* git.checkout(branch);
|
|
113
|
+
yield* withSpinner(
|
|
114
|
+
`Rebasing ${branch} onto ${newBase}`,
|
|
115
|
+
git.rebaseOnto(branch, newBase, oldBase),
|
|
116
|
+
).pipe(
|
|
117
|
+
Effect.catchTag("GitError", (e) =>
|
|
118
|
+
Effect.fail(
|
|
119
|
+
new StackError({
|
|
120
|
+
code: ErrorCode.REBASE_CONFLICT,
|
|
121
|
+
message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
89
124
|
),
|
|
90
|
-
)
|
|
91
|
-
|
|
125
|
+
);
|
|
126
|
+
yield* stacks.updateSyncedOnto(branch, newBaseTip);
|
|
127
|
+
}
|
|
92
128
|
synced.push(branch);
|
|
93
129
|
}
|
|
94
130
|
}).pipe(
|
package/src/commands/create.ts
CHANGED
|
@@ -99,6 +99,10 @@ export const create = Command.make("create", {
|
|
|
99
99
|
yield* stacks.addBranch(stackName, name, baseBranch === trunk ? undefined : baseBranch);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Record the fork-point for incremental sync
|
|
103
|
+
const baseTip = yield* git.revParse(baseBranch);
|
|
104
|
+
yield* stacks.updateSyncedOnto(name, baseTip);
|
|
105
|
+
|
|
102
106
|
if (json) {
|
|
103
107
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
104
108
|
yield* Console.log(
|
package/src/commands/doctor.ts
CHANGED
|
@@ -8,7 +8,7 @@ const fixFlag = Flag.boolean("fix").pipe(Flag.withDescription("Auto-fix issues w
|
|
|
8
8
|
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
9
9
|
|
|
10
10
|
interface Finding {
|
|
11
|
-
type: "stale_branch" | "missing_trunk" | "duplicate_branch" | "parse_error";
|
|
11
|
+
type: "stale_branch" | "missing_trunk" | "duplicate_branch" | "stale_fork_point" | "parse_error";
|
|
12
12
|
message: string;
|
|
13
13
|
fixed: boolean;
|
|
14
14
|
}
|
|
@@ -109,6 +109,33 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Check 4: syncedOnto entries point at valid commits
|
|
113
|
+
for (const [branch, record] of Object.entries(data.branches)) {
|
|
114
|
+
if (record.syncedOnto == null) continue;
|
|
115
|
+
const valid = yield* git.revParse(record.syncedOnto).pipe(
|
|
116
|
+
Effect.as(true),
|
|
117
|
+
Effect.catchTag("GitError", () => Effect.succeed(false)),
|
|
118
|
+
);
|
|
119
|
+
if (!valid) {
|
|
120
|
+
if (fix) {
|
|
121
|
+
yield* stacks
|
|
122
|
+
.updateSyncedOnto(branch, null)
|
|
123
|
+
.pipe(Effect.catchTag("StackError", () => Effect.void));
|
|
124
|
+
findings.push({
|
|
125
|
+
type: "stale_fork_point",
|
|
126
|
+
message: `Cleared stale syncedOnto for "${branch}" (commit ${record.syncedOnto.slice(0, 7)} no longer exists)`,
|
|
127
|
+
fixed: true,
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
findings.push({
|
|
131
|
+
type: "stale_fork_point",
|
|
132
|
+
message: `Branch "${branch}" has stale syncedOnto (commit ${record.syncedOnto.slice(0, 7)} no longer exists)`,
|
|
133
|
+
fixed: false,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
112
139
|
if (json) {
|
|
113
140
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
114
141
|
yield* Console.log(JSON.stringify({ findings }, null, 2));
|
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;
|
|
@@ -105,18 +110,43 @@ export const sync = Command.make("sync", {
|
|
|
105
110
|
const results: SyncResult[] = [];
|
|
106
111
|
|
|
107
112
|
if (dryRun) {
|
|
108
|
-
results.push({ name: trunk, action: "
|
|
113
|
+
results.push({ name: trunk, action: "rebased", base: originTrunk });
|
|
109
114
|
if (!json) {
|
|
110
|
-
yield* Console.error(
|
|
115
|
+
yield* Console.error(`${trunk}: rebase onto ${originTrunk}`);
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
for (let i = startIdx; i < branches.length; i++) {
|
|
114
119
|
const branch = branches[i];
|
|
115
120
|
if (branch === undefined) continue;
|
|
116
121
|
const base = effectiveBase(i, originTrunk);
|
|
117
|
-
|
|
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 });
|
|
118
142
|
if (!json) {
|
|
119
|
-
|
|
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}`);
|
|
120
150
|
}
|
|
121
151
|
}
|
|
122
152
|
|
|
@@ -124,9 +154,12 @@ export const sync = Command.make("sync", {
|
|
|
124
154
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
125
155
|
yield* Console.log(JSON.stringify({ branches: results }, null, 2));
|
|
126
156
|
} else {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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(", ")}`);
|
|
130
163
|
}
|
|
131
164
|
return;
|
|
132
165
|
}
|
|
@@ -151,29 +184,79 @@ export const sync = Command.make("sync", {
|
|
|
151
184
|
if (branch === undefined) continue;
|
|
152
185
|
const newBase = effectiveBase(i, originTrunk);
|
|
153
186
|
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
+
}
|
|
177
260
|
}
|
|
178
261
|
}).pipe(
|
|
179
262
|
Effect.ensuring(
|
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, () => {
|
|
@@ -223,6 +231,9 @@ export class GitService extends ServiceMap.Service<
|
|
|
223
231
|
|
|
224
232
|
deleteRemoteBranch: (branch) =>
|
|
225
233
|
run(["push", "origin", "--delete", branch]).pipe(Effect.asVoid),
|
|
234
|
+
|
|
235
|
+
treeMergeSync: () => Effect.succeed({ action: "conflict" as const }),
|
|
236
|
+
supportsTreeMerge: () => false,
|
|
226
237
|
};
|
|
227
238
|
});
|
|
228
239
|
|
|
@@ -249,6 +260,8 @@ export class GitService extends ServiceMap.Service<
|
|
|
249
260
|
commitAmend: () => Effect.void,
|
|
250
261
|
fetch: () => Effect.void,
|
|
251
262
|
deleteRemoteBranch: () => Effect.void,
|
|
263
|
+
treeMergeSync: () => Effect.succeed({ action: "conflict" as const }),
|
|
264
|
+
supportsTreeMerge: () => false,
|
|
252
265
|
...impl,
|
|
253
266
|
});
|
|
254
267
|
}
|
package/src/services/GitEs.ts
CHANGED
|
@@ -359,6 +359,43 @@ export const GitEsLayer = Layer.effect(
|
|
|
359
359
|
},
|
|
360
360
|
catch: (error) => makeGitError(`es-git.deleteRemoteBranch ${branch}`, error),
|
|
361
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,
|
|
362
399
|
};
|
|
363
400
|
}),
|
|
364
401
|
);
|
package/src/services/Stack.ts
CHANGED
|
@@ -22,6 +22,7 @@ const StackRecordSchema = Schema.Struct({
|
|
|
22
22
|
const BranchRecordSchema = Schema.Struct({
|
|
23
23
|
stack: Schema.String,
|
|
24
24
|
parent: Schema.NullOr(Schema.String),
|
|
25
|
+
syncedOnto: Schema.optional(Schema.NullOr(Schema.String)),
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
const StackFileV2Schema = Schema.Struct({
|
|
@@ -54,7 +55,14 @@ interface CanonicalStackFile {
|
|
|
54
55
|
readonly trunk: string;
|
|
55
56
|
readonly stacks: Readonly<Record<string, { readonly root: string }>>;
|
|
56
57
|
readonly branches: Readonly<
|
|
57
|
-
Record<
|
|
58
|
+
Record<
|
|
59
|
+
string,
|
|
60
|
+
{
|
|
61
|
+
readonly stack: string;
|
|
62
|
+
readonly parent: string | null;
|
|
63
|
+
readonly syncedOnto?: string | null | undefined;
|
|
64
|
+
}
|
|
65
|
+
>
|
|
58
66
|
>;
|
|
59
67
|
readonly mergedBranches: readonly string[];
|
|
60
68
|
}
|
|
@@ -224,7 +232,10 @@ const rewriteStackBranches = (
|
|
|
224
232
|
stackName: string,
|
|
225
233
|
branches: readonly string[],
|
|
226
234
|
): CanonicalStackFile => {
|
|
227
|
-
const nextBranches: Record<
|
|
235
|
+
const nextBranches: Record<
|
|
236
|
+
string,
|
|
237
|
+
{ stack: string; parent: string | null; syncedOnto?: string | null }
|
|
238
|
+
> = {
|
|
228
239
|
...data.branches,
|
|
229
240
|
};
|
|
230
241
|
|
|
@@ -237,9 +248,11 @@ const rewriteStackBranches = (
|
|
|
237
248
|
for (let i = 0; i < branches.length; i++) {
|
|
238
249
|
const branch = branches[i];
|
|
239
250
|
if (branch === undefined) continue;
|
|
251
|
+
const existing = data.branches[branch];
|
|
240
252
|
nextBranches[branch] = {
|
|
241
253
|
stack: stackName,
|
|
242
254
|
parent: i === 0 ? null : (branches[i - 1] ?? null),
|
|
255
|
+
...(existing?.syncedOnto != null ? { syncedOnto: existing.syncedOnto } : {}),
|
|
243
256
|
};
|
|
244
257
|
}
|
|
245
258
|
|
|
@@ -274,7 +287,10 @@ const renameStackRefs = (
|
|
|
274
287
|
if (stackRecord === undefined) return data;
|
|
275
288
|
|
|
276
289
|
const { [oldName]: _, ...restStacks } = data.stacks;
|
|
277
|
-
const branches: Record<
|
|
290
|
+
const branches: Record<
|
|
291
|
+
string,
|
|
292
|
+
{ stack: string; parent: string | null; syncedOnto?: string | null }
|
|
293
|
+
> = {};
|
|
278
294
|
|
|
279
295
|
for (const [branch, record] of Object.entries(data.branches)) {
|
|
280
296
|
branches[branch] = {
|
|
@@ -584,6 +600,33 @@ const makeStackService = ({
|
|
|
584
600
|
});
|
|
585
601
|
}),
|
|
586
602
|
|
|
603
|
+
getSyncedOnto: Effect.fn("StackService.getSyncedOnto")(function* (branch: string) {
|
|
604
|
+
const data = yield* loadData();
|
|
605
|
+
const record = data.branches[branch];
|
|
606
|
+
if (record === undefined) return null;
|
|
607
|
+
return record.syncedOnto ?? null;
|
|
608
|
+
}),
|
|
609
|
+
|
|
610
|
+
updateSyncedOnto: Effect.fn("StackService.updateSyncedOnto")(function* (
|
|
611
|
+
branch: string,
|
|
612
|
+
oid: string | null,
|
|
613
|
+
) {
|
|
614
|
+
const data = yield* loadData();
|
|
615
|
+
const record = data.branches[branch];
|
|
616
|
+
if (record === undefined) {
|
|
617
|
+
return yield* new StackError({
|
|
618
|
+
message: `Branch "${branch}" not found in stack metadata`,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
yield* saveData({
|
|
622
|
+
...data,
|
|
623
|
+
branches: {
|
|
624
|
+
...data.branches,
|
|
625
|
+
[branch]: { ...record, syncedOnto: oid },
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
}),
|
|
629
|
+
|
|
587
630
|
getTrunk: Effect.fn("StackService.getTrunk")(function* () {
|
|
588
631
|
const data = yield* loadData();
|
|
589
632
|
return data.trunk;
|
|
@@ -630,6 +673,11 @@ export class StackService extends ServiceMap.Service<
|
|
|
630
673
|
branch: string,
|
|
631
674
|
) => Effect.Effect<{ name: string; stack: Stack } | null, StackError>;
|
|
632
675
|
readonly detectTrunkCandidate: () => Effect.Effect<Option.Option<string>, never>;
|
|
676
|
+
readonly getSyncedOnto: (branch: string) => Effect.Effect<string | null, StackError>;
|
|
677
|
+
readonly updateSyncedOnto: (
|
|
678
|
+
branch: string,
|
|
679
|
+
oid: string | null,
|
|
680
|
+
) => Effect.Effect<void, StackError>;
|
|
633
681
|
readonly getTrunk: () => Effect.Effect<string, StackError>;
|
|
634
682
|
readonly setTrunk: (name: string) => Effect.Effect<void, StackError>;
|
|
635
683
|
}
|