@cvr/stacked 0.1.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/README.md +81 -0
- package/bin/stacked +0 -0
- package/package.json +46 -0
- package/scripts/build.ts +53 -0
- package/skills/stacked/SKILL.md +181 -0
- package/src/commands/adopt.ts +39 -0
- package/src/commands/bottom.ts +29 -0
- package/src/commands/checkout.ts +15 -0
- package/src/commands/create.ts +46 -0
- package/src/commands/delete.ts +55 -0
- package/src/commands/index.ts +34 -0
- package/src/commands/list.ts +50 -0
- package/src/commands/log.ts +34 -0
- package/src/commands/restack.ts +40 -0
- package/src/commands/submit.ts +68 -0
- package/src/commands/sync.ts +42 -0
- package/src/commands/top.ts +29 -0
- package/src/commands/trunk.ts +21 -0
- package/src/errors/index.ts +15 -0
- package/src/main.ts +22 -0
- package/src/services/Git.ts +127 -0
- package/src/services/GitHub.ts +126 -0
- package/src/services/Stack.ts +299 -0
- package/tsconfig.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# stacked
|
|
2
|
+
|
|
3
|
+
Branch-based stacked PR manager. Tracks parent-child branch relationships, automates rebasing, and creates/updates GitHub PRs via `gh`.
|
|
4
|
+
|
|
5
|
+
Built with [Effect v4](https://effect.website) and [Bun](https://bun.sh).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun run build # compiles binary to bin/stacked + symlinks to ~/.bun/bin/
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
# Set trunk branch (default: main)
|
|
17
|
+
stacked trunk develop
|
|
18
|
+
|
|
19
|
+
# Create a stack: branch from trunk
|
|
20
|
+
stacked create feat-auth
|
|
21
|
+
|
|
22
|
+
# Stack another branch on top
|
|
23
|
+
stacked create feat-auth-ui
|
|
24
|
+
|
|
25
|
+
# See the stack
|
|
26
|
+
stacked list
|
|
27
|
+
|
|
28
|
+
# Navigate
|
|
29
|
+
stacked top
|
|
30
|
+
stacked bottom
|
|
31
|
+
stacked checkout feat-auth
|
|
32
|
+
|
|
33
|
+
# After editing mid-stack, rebase children
|
|
34
|
+
stacked restack
|
|
35
|
+
|
|
36
|
+
# Sync entire stack with latest trunk
|
|
37
|
+
stacked sync
|
|
38
|
+
|
|
39
|
+
# Push all branches + create/update PRs
|
|
40
|
+
stacked submit
|
|
41
|
+
stacked submit --draft
|
|
42
|
+
stacked submit --dry-run
|
|
43
|
+
|
|
44
|
+
# Adopt an existing branch into the stack
|
|
45
|
+
stacked adopt existing-branch --after feat-auth
|
|
46
|
+
|
|
47
|
+
# View commits per branch
|
|
48
|
+
stacked log
|
|
49
|
+
|
|
50
|
+
# Remove a branch from the stack
|
|
51
|
+
stacked delete feat-auth-ui
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
| Command | Description |
|
|
57
|
+
| ----------------- | ---------------------------------------- |
|
|
58
|
+
| `trunk [name]` | Get/set trunk branch |
|
|
59
|
+
| `create <name>` | Create branch on top of current |
|
|
60
|
+
| `list` | Show stack with current branch indicator |
|
|
61
|
+
| `checkout <name>` | Switch to branch |
|
|
62
|
+
| `top` | Jump to top of stack |
|
|
63
|
+
| `bottom` | Jump to bottom of stack |
|
|
64
|
+
| `sync` | Fetch + rebase entire stack on trunk |
|
|
65
|
+
| `restack` | Rebase children after mid-stack edits |
|
|
66
|
+
| `delete <name>` | Remove branch from stack + git |
|
|
67
|
+
| `submit` | Push all + create/update PRs via `gh` |
|
|
68
|
+
| `adopt <branch>` | Add existing branch to stack |
|
|
69
|
+
| `log` | Show commits grouped by branch |
|
|
70
|
+
|
|
71
|
+
## Data Model
|
|
72
|
+
|
|
73
|
+
Stack metadata lives in `.git/stacked.json`. Each branch's parent is implied by array position — `branches[0]`'s parent is trunk, `branches[n]`'s parent is `branches[n-1]`.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
bun run dev -- --help # run from source
|
|
79
|
+
bun run gate # typecheck + lint + fmt + test + build
|
|
80
|
+
bun test # tests only
|
|
81
|
+
```
|
package/bin/stacked
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cvr/stacked",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"bin": {
|
|
5
|
+
"stacked": "./bin/stacked"
|
|
6
|
+
},
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"scripts",
|
|
10
|
+
"skills",
|
|
11
|
+
"tsconfig.json"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "bun run src/main.ts",
|
|
16
|
+
"build": "bun run scripts/build.ts",
|
|
17
|
+
"link": "bun run build",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"lint": "oxlint",
|
|
20
|
+
"lint:fix": "oxlint --fix",
|
|
21
|
+
"fmt": "oxfmt",
|
|
22
|
+
"fmt:check": "oxfmt --check",
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"gate": "concurrently -n type,lint,fmt,test,build -c blue,yellow,magenta,green,cyan \"bun run typecheck\" \"bun run lint\" \"bun run fmt\" \"bun run test\" \"bun run build\"",
|
|
25
|
+
"version": "changeset version",
|
|
26
|
+
"release": "changeset publish",
|
|
27
|
+
"postinstall": "bun run build",
|
|
28
|
+
"prepare": "effect-language-service patch && lefthook install"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@effect/platform-bun": "4.0.0-beta.12",
|
|
32
|
+
"effect": "4.0.0-beta.12"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@changesets/changelog-github": "^0.5.2",
|
|
36
|
+
"@changesets/cli": "^2.29.8",
|
|
37
|
+
"@effect/language-service": "^0.76.0",
|
|
38
|
+
"@types/bun": "^1.3.9",
|
|
39
|
+
"concurrently": "^9.2.1",
|
|
40
|
+
"effect-bun-test": "^0.2.1",
|
|
41
|
+
"lefthook": "^2.1.1",
|
|
42
|
+
"oxfmt": "^0.35.0",
|
|
43
|
+
"oxlint": "^1.50.0",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/scripts/build.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdirSync, lstatSync, unlinkSync, symlinkSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
const rootDir = join(__dirname, "..");
|
|
9
|
+
|
|
10
|
+
console.log("Building stacked...");
|
|
11
|
+
|
|
12
|
+
const binDir = join(rootDir, "bin");
|
|
13
|
+
mkdirSync(binDir, { recursive: true });
|
|
14
|
+
|
|
15
|
+
const platform =
|
|
16
|
+
process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
|
|
17
|
+
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
18
|
+
|
|
19
|
+
const buildResult = await Bun.build({
|
|
20
|
+
entrypoints: [join(rootDir, "src/main.ts")],
|
|
21
|
+
target: "bun",
|
|
22
|
+
minify: false,
|
|
23
|
+
compile: {
|
|
24
|
+
target: `bun-${platform}-${arch}`,
|
|
25
|
+
outfile: join(binDir, "stacked"),
|
|
26
|
+
autoloadBunfig: false,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!buildResult.success) {
|
|
31
|
+
console.error("Build failed:");
|
|
32
|
+
for (const log of buildResult.logs) {
|
|
33
|
+
console.error(log);
|
|
34
|
+
}
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(`Binary built: ${join(binDir, "stacked")}`);
|
|
39
|
+
|
|
40
|
+
const home = process.env["HOME"] ?? os.homedir();
|
|
41
|
+
const bunBin = join(home, ".bun", "bin", "stacked");
|
|
42
|
+
try {
|
|
43
|
+
try {
|
|
44
|
+
lstatSync(bunBin);
|
|
45
|
+
unlinkSync(bunBin);
|
|
46
|
+
} catch {
|
|
47
|
+
// doesn't exist
|
|
48
|
+
}
|
|
49
|
+
symlinkSync(join(binDir, "stacked"), bunBin);
|
|
50
|
+
console.log(`Symlinked to: ${bunBin}`);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.log(`Could not symlink to ${bunBin}: ${e}`);
|
|
53
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stacked
|
|
3
|
+
description: Use the `stacked` CLI to manage stacked PRs. Use when the user wants to create branch stacks, rebase, sync, submit PRs, or navigate stacked branches. Triggers on "stacked", "stack", "stacked PRs", branch stacking workflows, or any git workflow involving parent-child branch relationships.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# stacked
|
|
7
|
+
|
|
8
|
+
Branch-based stacked PR manager. Manages parent-child branch relationships, automates rebasing, creates/updates GitHub PRs via `gh`.
|
|
9
|
+
|
|
10
|
+
Key idea: **branches** are the unit, not commits. Each branch in a stack has exactly one parent — position in the stack determines lineage.
|
|
11
|
+
|
|
12
|
+
## Navigation
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
What do you need?
|
|
16
|
+
├─ Start a new stack → §Creating a Stack
|
|
17
|
+
├─ Add branches to a stack → §Creating a Stack
|
|
18
|
+
├─ See current stack → §Viewing the Stack
|
|
19
|
+
├─ Navigate between branches → §Navigation
|
|
20
|
+
├─ Rebase after changes → §Rebasing
|
|
21
|
+
├─ Push + create PRs → §Submitting
|
|
22
|
+
├─ Adopt existing branches → §Adopting Branches
|
|
23
|
+
├─ Remove a branch → §Deleting
|
|
24
|
+
└─ Troubleshooting → §Gotchas
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Reference
|
|
28
|
+
|
|
29
|
+
| Command | What it does |
|
|
30
|
+
| ------------------------- | ---------------------------------------------- |
|
|
31
|
+
| `stacked trunk [name]` | Get/set trunk branch (default: main) |
|
|
32
|
+
| `stacked create <name>` | Create branch on top of current branch |
|
|
33
|
+
| `stacked list` | Show stack with current branch indicator |
|
|
34
|
+
| `stacked checkout <name>` | Switch to branch in stack |
|
|
35
|
+
| `stacked top` | Jump to top of stack |
|
|
36
|
+
| `stacked bottom` | Jump to bottom of stack |
|
|
37
|
+
| `stacked sync` | Fetch + rebase entire stack on trunk |
|
|
38
|
+
| `stacked restack` | Rebase children after mid-stack edits |
|
|
39
|
+
| `stacked delete <name>` | Remove branch from stack + delete git branch |
|
|
40
|
+
| `stacked submit` | Push all branches + create/update PRs via `gh` |
|
|
41
|
+
| `stacked adopt <branch>` | Add existing git branch into the stack |
|
|
42
|
+
| `stacked log` | Show commits grouped by branch |
|
|
43
|
+
|
|
44
|
+
## Setup
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
# Set trunk if not "main"
|
|
48
|
+
stacked trunk develop
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires `gh` CLI installed and authenticated for `submit`.
|
|
52
|
+
|
|
53
|
+
## Creating a Stack
|
|
54
|
+
|
|
55
|
+
Start from trunk, build upward. Each `create` branches off the current branch.
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
git checkout main
|
|
59
|
+
stacked create feat-auth # branches off main
|
|
60
|
+
# ... make commits ...
|
|
61
|
+
stacked create feat-auth-ui # branches off feat-auth
|
|
62
|
+
# ... make commits ...
|
|
63
|
+
stacked create feat-auth-tests # branches off feat-auth-ui
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Result: `main → feat-auth → feat-auth-ui → feat-auth-tests`
|
|
67
|
+
|
|
68
|
+
Use `--from` to branch from a specific branch instead of current:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
stacked create hotfix --from feat-auth
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Viewing the Stack
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
stacked list # shows branches with ► on current
|
|
78
|
+
stacked log # shows commits grouped by branch
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Navigation
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
stacked checkout feat-auth # switch to specific branch
|
|
85
|
+
stacked top # jump to top of stack
|
|
86
|
+
stacked bottom # jump to bottom (trunk-adjacent)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Rebasing
|
|
90
|
+
|
|
91
|
+
### After mid-stack changes
|
|
92
|
+
|
|
93
|
+
Edit a branch mid-stack, then rebase everything above it:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
stacked checkout feat-auth
|
|
97
|
+
# ... make changes, commit ...
|
|
98
|
+
stacked restack # rebases feat-auth-ui and feat-auth-tests
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Sync with trunk
|
|
102
|
+
|
|
103
|
+
Pull latest trunk and rebase the entire stack bottom-to-top:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
stacked sync
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
This fetches, then rebases each branch onto its parent starting from the bottom.
|
|
110
|
+
|
|
111
|
+
## Submitting
|
|
112
|
+
|
|
113
|
+
Push all stack branches and create/update GitHub PRs with correct base branches:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
stacked submit # push + create/update PRs
|
|
117
|
+
stacked submit --draft # create as draft PRs
|
|
118
|
+
stacked submit --force # force push
|
|
119
|
+
stacked submit --dry-run # show what would happen
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Each PR targets its parent branch (not trunk), preserving the stack structure on GitHub.
|
|
123
|
+
|
|
124
|
+
## Adopting Branches
|
|
125
|
+
|
|
126
|
+
Bring an existing git branch into the stack:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
stacked adopt existing-branch # append to top
|
|
130
|
+
stacked adopt existing-branch --after feat-auth # insert after specific branch
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Deleting
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
stacked delete feat-auth-ui # removes from stack + deletes git branch
|
|
137
|
+
stacked delete feat-auth-ui --force # skip confirmation
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Data Model
|
|
141
|
+
|
|
142
|
+
Stack metadata lives in `.git/stacked.json`. Each branch's parent is implied by array position:
|
|
143
|
+
|
|
144
|
+
- `branches[0]` → parent is trunk
|
|
145
|
+
- `branches[n]` → parent is `branches[n-1]`
|
|
146
|
+
|
|
147
|
+
A repo can have multiple independent stacks. The current stack is determined by which branch you're on.
|
|
148
|
+
|
|
149
|
+
## Typical Workflow
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
# 1. Start a stack
|
|
153
|
+
stacked create feat-auth
|
|
154
|
+
# ... work, commit ...
|
|
155
|
+
|
|
156
|
+
# 2. Stack more branches
|
|
157
|
+
stacked create feat-auth-ui
|
|
158
|
+
# ... work, commit ...
|
|
159
|
+
|
|
160
|
+
# 3. Need to fix something mid-stack
|
|
161
|
+
stacked checkout feat-auth
|
|
162
|
+
# ... fix, commit ...
|
|
163
|
+
stacked restack # rebase children
|
|
164
|
+
|
|
165
|
+
# 4. Sync with latest main
|
|
166
|
+
stacked sync
|
|
167
|
+
|
|
168
|
+
# 5. Submit for review
|
|
169
|
+
stacked submit --draft
|
|
170
|
+
|
|
171
|
+
# 6. After review, final submit
|
|
172
|
+
stacked submit
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Gotchas
|
|
176
|
+
|
|
177
|
+
- `stacked sync` rebases bottom-to-top — resolve conflicts one branch at a time
|
|
178
|
+
- `stacked submit` requires `gh` CLI authenticated (`gh auth login`)
|
|
179
|
+
- PRs target parent branches, not trunk — this is intentional for stacked review
|
|
180
|
+
- Trunk defaults to `main` — use `stacked trunk <name>` if your default branch differs
|
|
181
|
+
- Rebase conflicts mid-stack will pause the operation — resolve and re-run
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect, Option } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
4
|
+
import { StackService } from "../services/Stack.js";
|
|
5
|
+
import { StackError } from "../errors/index.js";
|
|
6
|
+
|
|
7
|
+
const branchArg = Argument.string("branch");
|
|
8
|
+
const afterFlag = Flag.string("after").pipe(Flag.optional, Flag.withAlias("a"));
|
|
9
|
+
|
|
10
|
+
export const adopt = Command.make("adopt", { branch: branchArg, after: afterFlag }).pipe(
|
|
11
|
+
Command.withDescription("Adopt existing git branch into current stack"),
|
|
12
|
+
Command.withHandler(({ branch, after }) =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const git = yield* GitService;
|
|
15
|
+
const stacks = yield* StackService;
|
|
16
|
+
|
|
17
|
+
const exists = yield* git.branchExists(branch);
|
|
18
|
+
if (!exists) {
|
|
19
|
+
return yield* new StackError({ message: `Branch "${branch}" does not exist` });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const result = yield* stacks.currentStack();
|
|
23
|
+
if (result === null) {
|
|
24
|
+
const trunk = yield* stacks.getTrunk();
|
|
25
|
+
const currentBranch = yield* git.currentBranch();
|
|
26
|
+
if (currentBranch === trunk) {
|
|
27
|
+
yield* stacks.createStack(branch, [branch]);
|
|
28
|
+
} else {
|
|
29
|
+
yield* stacks.createStack(currentBranch, [currentBranch, branch]);
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
const afterBranch = Option.isSome(after) ? after.value : undefined;
|
|
33
|
+
yield* stacks.addBranch(result.name, branch, afterBranch);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
yield* Console.log(`Adopted ${branch} into stack`);
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Command } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
4
|
+
import { StackService } from "../services/Stack.js";
|
|
5
|
+
|
|
6
|
+
export const bottom = Command.make("bottom").pipe(
|
|
7
|
+
Command.withDescription("Checkout bottom branch of stack"),
|
|
8
|
+
Command.withHandler(() =>
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const git = yield* GitService;
|
|
11
|
+
const stacks = yield* StackService;
|
|
12
|
+
|
|
13
|
+
const result = yield* stacks.currentStack();
|
|
14
|
+
if (result === null) {
|
|
15
|
+
yield* Console.error("Not on a stacked branch");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const bottomBranch = result.stack.branches[0];
|
|
20
|
+
if (bottomBranch === undefined) {
|
|
21
|
+
yield* Console.error("Stack is empty");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
yield* git.checkout(bottomBranch);
|
|
26
|
+
yield* Console.log(`Switched to ${bottomBranch}`);
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Argument, Command } from "effect/unstable/cli";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
4
|
+
|
|
5
|
+
const nameArg = Argument.string("name");
|
|
6
|
+
|
|
7
|
+
export const checkout = Command.make("checkout", { name: nameArg }).pipe(
|
|
8
|
+
Command.withDescription("Switch to branch in current stack"),
|
|
9
|
+
Command.withHandler(({ name }) =>
|
|
10
|
+
Effect.gen(function* () {
|
|
11
|
+
const git = yield* GitService;
|
|
12
|
+
yield* git.checkout(name);
|
|
13
|
+
}),
|
|
14
|
+
),
|
|
15
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect, Option } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
4
|
+
import { StackService } from "../services/Stack.js";
|
|
5
|
+
|
|
6
|
+
const nameArg = Argument.string("name");
|
|
7
|
+
const fromFlag = Flag.string("from").pipe(Flag.optional, Flag.withAlias("f"));
|
|
8
|
+
|
|
9
|
+
export const create = Command.make("create", { name: nameArg, from: fromFlag }).pipe(
|
|
10
|
+
Command.withDescription("Create a new branch on top of current branch in stack"),
|
|
11
|
+
Command.withHandler(({ name, from }) =>
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const git = yield* GitService;
|
|
14
|
+
const stacks = yield* StackService;
|
|
15
|
+
|
|
16
|
+
const currentBranch = yield* git.currentBranch();
|
|
17
|
+
const baseBranch = Option.isSome(from) ? from.value : currentBranch;
|
|
18
|
+
const trunk = yield* stacks.getTrunk();
|
|
19
|
+
|
|
20
|
+
const data = yield* stacks.load();
|
|
21
|
+
let stackName: string | null = null;
|
|
22
|
+
|
|
23
|
+
for (const [sName, stack] of Object.entries(data.stacks)) {
|
|
24
|
+
if (stack.branches.includes(baseBranch)) {
|
|
25
|
+
stackName = sName;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (stackName === null) {
|
|
31
|
+
if (baseBranch === trunk) {
|
|
32
|
+
stackName = name;
|
|
33
|
+
yield* stacks.createStack(name, []);
|
|
34
|
+
} else {
|
|
35
|
+
stackName = baseBranch;
|
|
36
|
+
yield* stacks.createStack(baseBranch, [baseBranch]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
yield* git.createBranch(name, baseBranch);
|
|
41
|
+
yield* stacks.addBranch(stackName, name, baseBranch === trunk ? undefined : baseBranch);
|
|
42
|
+
|
|
43
|
+
yield* Console.log(`Created branch ${name} on top of ${baseBranch}`);
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
4
|
+
import { StackService } from "../services/Stack.js";
|
|
5
|
+
import { StackError } from "../errors/index.js";
|
|
6
|
+
|
|
7
|
+
const nameArg = Argument.string("name");
|
|
8
|
+
const forceFlag = Flag.boolean("force").pipe(Flag.withAlias("f"));
|
|
9
|
+
|
|
10
|
+
export const deleteCmd = Command.make("delete", { name: nameArg, force: forceFlag }).pipe(
|
|
11
|
+
Command.withDescription("Remove branch from stack and delete git branch"),
|
|
12
|
+
Command.withHandler(({ name, force }) =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const git = yield* GitService;
|
|
15
|
+
const stacks = yield* StackService;
|
|
16
|
+
|
|
17
|
+
const currentBranch = yield* git.currentBranch();
|
|
18
|
+
const data = yield* stacks.load();
|
|
19
|
+
|
|
20
|
+
let stackName: string | null = null;
|
|
21
|
+
for (const [sName, stack] of Object.entries(data.stacks)) {
|
|
22
|
+
if (stack.branches.includes(name)) {
|
|
23
|
+
stackName = sName;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (stackName === null) {
|
|
29
|
+
return yield* new StackError({ message: `Branch "${name}" not found in any stack` });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const stack = data.stacks[stackName];
|
|
33
|
+
if (stack === undefined) {
|
|
34
|
+
return yield* new StackError({ message: `Stack "${stackName}" not found` });
|
|
35
|
+
}
|
|
36
|
+
const idx = stack.branches.indexOf(name);
|
|
37
|
+
|
|
38
|
+
if (idx < stack.branches.length - 1 && !force) {
|
|
39
|
+
return yield* new StackError({
|
|
40
|
+
message: `Branch "${name}" has children. Use --force to delete anyway.`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (currentBranch === name) {
|
|
45
|
+
const parent = idx === 0 ? data.trunk : (stack.branches[idx - 1] ?? data.trunk);
|
|
46
|
+
yield* git.checkout(parent);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
yield* stacks.removeBranch(stackName, name);
|
|
50
|
+
yield* git.deleteBranch(name, force);
|
|
51
|
+
|
|
52
|
+
yield* Console.log(`Deleted ${name}`);
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Command } from "effect/unstable/cli";
|
|
2
|
+
import { trunk } from "./trunk.js";
|
|
3
|
+
import { create } from "./create.js";
|
|
4
|
+
import { list } from "./list.js";
|
|
5
|
+
import { checkout } from "./checkout.js";
|
|
6
|
+
import { top } from "./top.js";
|
|
7
|
+
import { bottom } from "./bottom.js";
|
|
8
|
+
import { sync } from "./sync.js";
|
|
9
|
+
import { restack } from "./restack.js";
|
|
10
|
+
import { deleteCmd } from "./delete.js";
|
|
11
|
+
import { submit } from "./submit.js";
|
|
12
|
+
import { adopt } from "./adopt.js";
|
|
13
|
+
import { log } from "./log.js";
|
|
14
|
+
|
|
15
|
+
const root = Command.make("stacked").pipe(
|
|
16
|
+
Command.withDescription("Branch-based stacked PR manager"),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export const command = root.pipe(
|
|
20
|
+
Command.withSubcommands([
|
|
21
|
+
trunk,
|
|
22
|
+
create,
|
|
23
|
+
list,
|
|
24
|
+
checkout,
|
|
25
|
+
top,
|
|
26
|
+
bottom,
|
|
27
|
+
sync,
|
|
28
|
+
restack,
|
|
29
|
+
deleteCmd,
|
|
30
|
+
submit,
|
|
31
|
+
adopt,
|
|
32
|
+
log,
|
|
33
|
+
]),
|
|
34
|
+
);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Command } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
4
|
+
import { StackService } from "../services/Stack.js";
|
|
5
|
+
|
|
6
|
+
export const list = Command.make("list").pipe(
|
|
7
|
+
Command.withDescription("Show current stack with branch status"),
|
|
8
|
+
Command.withHandler(() =>
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const git = yield* GitService;
|
|
11
|
+
const stacks = yield* StackService;
|
|
12
|
+
|
|
13
|
+
const currentBranch = yield* git.currentBranch();
|
|
14
|
+
const data = yield* stacks.load();
|
|
15
|
+
const trunk = data.trunk;
|
|
16
|
+
|
|
17
|
+
let currentStackName: string | null = null;
|
|
18
|
+
for (const [name, stack] of Object.entries(data.stacks)) {
|
|
19
|
+
if (stack.branches.includes(currentBranch)) {
|
|
20
|
+
currentStackName = name;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (currentStackName === null) {
|
|
26
|
+
yield* Console.log("Not on a stacked branch");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const stack = data.stacks[currentStackName];
|
|
31
|
+
if (stack === undefined) return;
|
|
32
|
+
const lines: string[] = [];
|
|
33
|
+
|
|
34
|
+
lines.push(`Stack: ${currentStackName}`);
|
|
35
|
+
lines.push(`Trunk: ${trunk}`);
|
|
36
|
+
lines.push("");
|
|
37
|
+
|
|
38
|
+
for (let i = stack.branches.length - 1; i >= 0; i--) {
|
|
39
|
+
const branch = stack.branches[i];
|
|
40
|
+
if (branch === undefined) continue;
|
|
41
|
+
const isCurrent = branch === currentBranch;
|
|
42
|
+
const marker = isCurrent ? "* " : " ";
|
|
43
|
+
const prefix = i === 0 ? "└─" : "├─";
|
|
44
|
+
lines.push(`${marker}${prefix} ${branch}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
yield* Console.log(lines.join("\n"));
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Command } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
4
|
+
import { StackService } from "../services/Stack.js";
|
|
5
|
+
|
|
6
|
+
export const log = Command.make("log").pipe(
|
|
7
|
+
Command.withDescription("Show commits across all branches in stack"),
|
|
8
|
+
Command.withHandler(() =>
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const git = yield* GitService;
|
|
11
|
+
const stacks = yield* StackService;
|
|
12
|
+
|
|
13
|
+
const result = yield* stacks.currentStack();
|
|
14
|
+
if (result === null) {
|
|
15
|
+
yield* Console.error("Not on a stacked branch");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const trunk = yield* stacks.getTrunk();
|
|
20
|
+
const { branches } = result.stack;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < branches.length; i++) {
|
|
23
|
+
const branch = branches[i];
|
|
24
|
+
if (branch === undefined) continue;
|
|
25
|
+
const base = i === 0 ? trunk : (branches[i - 1] ?? trunk);
|
|
26
|
+
yield* Console.log(`\n── ${branch} ──`);
|
|
27
|
+
const rangeLog = yield* git
|
|
28
|
+
.log(`${base}..${branch}`, { oneline: true })
|
|
29
|
+
.pipe(Effect.catch(() => Effect.succeed("(no commits)")));
|
|
30
|
+
yield* Console.log(rangeLog || "(no new commits)");
|
|
31
|
+
}
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
);
|