@cvr/stacked 0.1.0 → 0.3.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 CHANGED
@@ -25,17 +25,23 @@ stacked create feat-auth-ui
25
25
  # See the stack
26
26
  stacked list
27
27
 
28
+ # See a specific stack by name
29
+ stacked list feat-auth
30
+
31
+ # List all stacks in the repo
32
+ stacked stacks
33
+
28
34
  # Navigate
29
35
  stacked top
30
36
  stacked bottom
31
37
  stacked checkout feat-auth
32
38
 
33
- # After editing mid-stack, rebase children
34
- stacked restack
35
-
36
39
  # Sync entire stack with latest trunk
37
40
  stacked sync
38
41
 
42
+ # After editing mid-stack, rebase only children
43
+ stacked sync --from feat-auth
44
+
39
45
  # Push all branches + create/update PRs
40
46
  stacked submit
41
47
  stacked submit --draft
@@ -47,26 +53,36 @@ stacked adopt existing-branch --after feat-auth
47
53
  # View commits per branch
48
54
  stacked log
49
55
 
56
+ # Detect existing branch chains and register as stacks
57
+ stacked detect
58
+ stacked detect --dry-run
59
+
60
+ # Remove merged branches from stacks
61
+ stacked clean
62
+ stacked clean --dry-run
63
+
50
64
  # Remove a branch from the stack
51
65
  stacked delete feat-auth-ui
52
66
  ```
53
67
 
54
68
  ## Commands
55
69
 
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
+ | Command | Description |
71
+ | ----------------- | ------------------------------------------------------------- |
72
+ | `trunk [name]` | Get/set trunk branch |
73
+ | `create <name>` | Create branch on top of current |
74
+ | `list [stack]` | Show stack branches (defaults to current stack) |
75
+ | `stacks` | List all stacks in the repo |
76
+ | `checkout <name>` | Switch to branch |
77
+ | `top` | Jump to top of stack |
78
+ | `bottom` | Jump to bottom of stack |
79
+ | `sync` | Fetch + rebase stack on trunk (--from to start from a branch) |
80
+ | `detect` | Detect branch chains and register as stacks (--dry-run) |
81
+ | `clean` | Remove merged branches from stacks (--dry-run to preview) |
82
+ | `delete <name>` | Remove branch from stack + git |
83
+ | `submit` | Push all + create/update PRs via `gh` |
84
+ | `adopt <branch>` | Add existing branch to stack |
85
+ | `log` | Show commits grouped by branch |
70
86
 
71
87
  ## Data Model
72
88
 
package/bin/stacked CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@cvr/stacked",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/cevr/stacked"
7
+ },
4
8
  "bin": {
5
9
  "stacked": "./bin/stacked"
6
10
  },
@@ -20,26 +20,30 @@ What do you need?
20
20
  ├─ Rebase after changes → §Rebasing
21
21
  ├─ Push + create PRs → §Submitting
22
22
  ├─ Adopt existing branches → §Adopting Branches
23
+ ├─ Detect existing branches → §Detecting Existing Branches
24
+ ├─ Clean up merged branches → §Cleaning Up Merged Branches
23
25
  ├─ Remove a branch → §Deleting
24
26
  └─ Troubleshooting → §Gotchas
25
27
  ```
26
28
 
27
29
  ## Quick Reference
28
30
 
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 |
31
+ | Command | What it does |
32
+ | ------------------------- | ------------------------------------------------------------- |
33
+ | `stacked trunk [name]` | Get/set trunk branch (default: main) |
34
+ | `stacked create <name>` | Create branch on top of current branch |
35
+ | `stacked list [stack]` | Show stack branches (defaults to current stack) |
36
+ | `stacked stacks` | List all stacks in the repo |
37
+ | `stacked checkout <name>` | Switch to branch in stack |
38
+ | `stacked top` | Jump to top of stack |
39
+ | `stacked bottom` | Jump to bottom of stack |
40
+ | `stacked sync` | Fetch + rebase stack on trunk (--from to start from a branch) |
41
+ | `stacked detect` | Detect branch chains and register as stacks (--dry-run) |
42
+ | `stacked clean` | Remove merged branches from stacks (--dry-run to preview) |
43
+ | `stacked delete <name>` | Remove branch from stack + delete git branch |
44
+ | `stacked submit` | Push all branches + create/update PRs via `gh` |
45
+ | `stacked adopt <branch>` | Add existing git branch into the stack |
46
+ | `stacked log` | Show commits grouped by branch |
43
47
 
44
48
  ## Setup
45
49
 
@@ -74,8 +78,10 @@ stacked create hotfix --from feat-auth
74
78
  ## Viewing the Stack
75
79
 
76
80
  ```sh
77
- stacked list # shows branches with ► on current
78
- stacked log # shows commits grouped by branch
81
+ stacked list # shows current stack's branches
82
+ stacked list feat-auth # shows a specific stack by name
83
+ stacked stacks # lists all stacks in the repo
84
+ stacked log # shows commits grouped by branch
79
85
  ```
80
86
 
81
87
  ## Navigation
@@ -86,28 +92,22 @@ stacked top # jump to top of stack
86
92
  stacked bottom # jump to bottom (trunk-adjacent)
87
93
  ```
88
94
 
89
- ## Rebasing
95
+ ## Syncing / Rebasing
90
96
 
91
- ### After mid-stack changes
92
-
93
- Edit a branch mid-stack, then rebase everything above it:
97
+ Fetch latest trunk and rebase the entire stack bottom-to-top:
94
98
 
95
99
  ```sh
96
- stacked checkout feat-auth
97
- # ... make changes, commit ...
98
- stacked restack # rebases feat-auth-ui and feat-auth-tests
100
+ stacked sync
99
101
  ```
100
102
 
101
- ### Sync with trunk
102
-
103
- Pull latest trunk and rebase the entire stack bottom-to-top:
103
+ After mid-stack changes, rebase only the branches above a specific point:
104
104
 
105
105
  ```sh
106
- stacked sync
106
+ stacked checkout feat-auth
107
+ # ... make changes, commit ...
108
+ stacked sync --from feat-auth # rebases only children of feat-auth
107
109
  ```
108
110
 
109
- This fetches, then rebases each branch onto its parent starting from the bottom.
110
-
111
111
  ## Submitting
112
112
 
113
113
  Push all stack branches and create/update GitHub PRs with correct base branches:
@@ -130,6 +130,28 @@ stacked adopt existing-branch # append to top
130
130
  stacked adopt existing-branch --after feat-auth # insert after specific branch
131
131
  ```
132
132
 
133
+ ## Detecting Existing Branches
134
+
135
+ Auto-detect linear branch chains from git history and register them as stacks:
136
+
137
+ ```sh
138
+ stacked detect # scan and register branch chains
139
+ stacked detect --dry-run # preview what would be registered
140
+ ```
141
+
142
+ Only linear chains are detected. Forked branches (one parent with multiple children) are reported but skipped. Already-tracked branches are excluded.
143
+
144
+ ## Cleaning Up Merged Branches
145
+
146
+ After PRs are merged on GitHub, clean up the local branches and stack metadata:
147
+
148
+ ```sh
149
+ stacked clean # removes all merged branches from all stacks
150
+ stacked clean --dry-run # preview what would be removed
151
+ ```
152
+
153
+ `list` also shows merge status per branch (`[merged]`, `[closed]`, `[#N]` for open PRs).
154
+
133
155
  ## Deleting
134
156
 
135
157
  ```sh
@@ -160,7 +182,7 @@ stacked create feat-auth-ui
160
182
  # 3. Need to fix something mid-stack
161
183
  stacked checkout feat-auth
162
184
  # ... fix, commit ...
163
- stacked restack # rebase children
185
+ stacked sync --from feat-auth # rebase children
164
186
 
165
187
  # 4. Sync with latest main
166
188
  stacked sync
@@ -179,3 +201,4 @@ stacked submit
179
201
  - PRs target parent branches, not trunk — this is intentional for stacked review
180
202
  - Trunk defaults to `main` — use `stacked trunk <name>` if your default branch differs
181
203
  - Rebase conflicts mid-stack will pause the operation — resolve and re-run
204
+ - Forked branches (one parent, multiple children) are not supported — `detect` reports them but skips
@@ -0,0 +1,85 @@
1
+ import { Command, Flag } from "effect/unstable/cli";
2
+ import { Console, Effect } from "effect";
3
+ import { GitService } from "../services/Git.js";
4
+ import { GitHubService } from "../services/GitHub.js";
5
+ import { StackService } from "../services/Stack.js";
6
+
7
+ const dryRunFlag = Flag.boolean("dry-run");
8
+
9
+ export const clean = Command.make("clean", { dryRun: dryRunFlag }).pipe(
10
+ Command.withDescription("Remove merged branches from stacks (bottom-up)"),
11
+ Command.withHandler(({ dryRun }) =>
12
+ Effect.gen(function* () {
13
+ const git = yield* GitService;
14
+ const gh = yield* GitHubService;
15
+ const stacks = yield* StackService;
16
+
17
+ const currentBranch = yield* git.currentBranch();
18
+ const data = yield* stacks.load();
19
+
20
+ const toRemove: Array<{ stackName: string; branch: string }> = [];
21
+ const skippedMerged: Array<{ stackName: string; branch: string }> = [];
22
+
23
+ for (const [stackName, stack] of Object.entries(data.stacks)) {
24
+ let hitNonMerged = false;
25
+ for (const branch of stack.branches) {
26
+ const pr = yield* gh.getPR(branch).pipe(Effect.catch(() => Effect.succeed(null)));
27
+ const isMerged = pr !== null && pr.state === "MERGED";
28
+
29
+ if (!hitNonMerged && isMerged) {
30
+ toRemove.push({ stackName, branch });
31
+ } else {
32
+ if (!isMerged) hitNonMerged = true;
33
+ if (isMerged) skippedMerged.push({ stackName, branch });
34
+ }
35
+ }
36
+ }
37
+
38
+ if (toRemove.length === 0) {
39
+ yield* Console.log("Nothing to clean");
40
+ if (skippedMerged.length > 0) {
41
+ yield* Console.log(
42
+ `\nNote: ${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
43
+ );
44
+ for (const { branch, stackName } of skippedMerged) {
45
+ yield* Console.log(` ${branch} (${stackName})`);
46
+ }
47
+ }
48
+ return;
49
+ }
50
+
51
+ for (const { stackName, branch } of toRemove) {
52
+ if (dryRun) {
53
+ yield* Console.log(`Would remove ${branch} from ${stackName}`);
54
+ } else {
55
+ if (currentBranch === branch) {
56
+ const trunk = yield* stacks.getTrunk();
57
+ yield* git.checkout(trunk);
58
+ }
59
+ yield* stacks.removeBranch(stackName, branch);
60
+ yield* git.deleteBranch(branch, true).pipe(Effect.catch(() => Effect.void));
61
+ yield* Console.log(`Removed ${branch} from ${stackName}`);
62
+ }
63
+ }
64
+
65
+ if (dryRun) {
66
+ yield* Console.log(
67
+ `\n${toRemove.length} branch${toRemove.length === 1 ? "" : "es"} would be removed`,
68
+ );
69
+ } else {
70
+ yield* Console.log(
71
+ `\nCleaned ${toRemove.length} merged branch${toRemove.length === 1 ? "" : "es"}`,
72
+ );
73
+ }
74
+
75
+ if (skippedMerged.length > 0) {
76
+ yield* Console.log(
77
+ `\nNote: ${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
78
+ );
79
+ for (const { branch, stackName } of skippedMerged) {
80
+ yield* Console.log(` ${branch} (${stackName})`);
81
+ }
82
+ }
83
+ }),
84
+ ),
85
+ );
@@ -0,0 +1,126 @@
1
+ import { 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
+
6
+ const dryRunFlag = Flag.boolean("dry-run");
7
+
8
+ export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
9
+ Command.withDescription("Detect and register branch stacks from git history"),
10
+ Command.withHandler(({ dryRun }) =>
11
+ Effect.gen(function* () {
12
+ const git = yield* GitService;
13
+ const stacks = yield* StackService;
14
+
15
+ const trunk = yield* stacks.getTrunk();
16
+ const allBranches = yield* git.listBranches();
17
+ const candidates = allBranches.filter((b) => b !== trunk);
18
+
19
+ const data = yield* stacks.load();
20
+ const alreadyTracked = new Set(Object.values(data.stacks).flatMap((s) => [...s.branches]));
21
+ const untracked = candidates.filter((b) => !alreadyTracked.has(b));
22
+
23
+ if (untracked.length === 0) {
24
+ yield* Console.log("No untracked branches found");
25
+ return;
26
+ }
27
+
28
+ // Build parent map: for each branch, find its direct parent among other branches
29
+ // A parent is the closest ancestor — i.e., an ancestor that is not an ancestor of another ancestor
30
+ const childOf = new Map<string, string>();
31
+
32
+ for (const branch of untracked) {
33
+ const ancestors: string[] = [];
34
+
35
+ // Check trunk
36
+ const trunkIsAncestor = yield* git
37
+ .isAncestor(trunk, branch)
38
+ .pipe(Effect.catch(() => Effect.succeed(false)));
39
+ if (trunkIsAncestor) ancestors.push(trunk);
40
+
41
+ // Check other untracked branches
42
+ for (const other of untracked) {
43
+ if (other === branch) continue;
44
+ const is = yield* git
45
+ .isAncestor(other, branch)
46
+ .pipe(Effect.catch(() => Effect.succeed(false)));
47
+ if (is) ancestors.push(other);
48
+ }
49
+
50
+ if (ancestors.length === 0) continue;
51
+
52
+ // Find the closest ancestor — the one that is a descendant of all others
53
+ let closest = ancestors[0] ?? trunk;
54
+ for (let i = 1; i < ancestors.length; i++) {
55
+ const candidate = ancestors[i];
56
+ if (candidate === undefined) continue;
57
+ const candidateIsCloser = yield* git
58
+ .isAncestor(closest, candidate)
59
+ .pipe(Effect.catch(() => Effect.succeed(false)));
60
+ if (candidateIsCloser) closest = candidate;
61
+ }
62
+
63
+ childOf.set(branch, closest);
64
+ }
65
+
66
+ // Build linear chains from trunk
67
+ // Find branches whose parent is trunk (chain roots)
68
+ const chains: string[][] = [];
69
+ const roots = untracked.filter((b) => childOf.get(b) === trunk);
70
+
71
+ for (const root of roots) {
72
+ const chain = [root];
73
+ let current = root;
74
+
75
+ while (true) {
76
+ const children = untracked.filter((b) => childOf.get(b) === current);
77
+ const child = children[0];
78
+ if (children.length === 1 && child !== undefined) {
79
+ chain.push(child);
80
+ current = child;
81
+ } else {
82
+ // 0 children = end of chain, 2+ children = fork (skip)
83
+ break;
84
+ }
85
+ }
86
+
87
+ chains.push(chain);
88
+ }
89
+
90
+ if (chains.length === 0) {
91
+ yield* Console.log("No linear branch chains detected");
92
+ return;
93
+ }
94
+
95
+ for (const chain of chains) {
96
+ const name = chain[0];
97
+ if (name === undefined) continue;
98
+ if (dryRun) {
99
+ yield* Console.log(`Would create stack "${name}": ${chain.join(" → ")}`);
100
+ } else {
101
+ yield* stacks.createStack(name, chain);
102
+ yield* Console.log(`Created stack "${name}": ${chain.join(" → ")}`);
103
+ }
104
+ }
105
+
106
+ if (dryRun) {
107
+ yield* Console.log(
108
+ `\n${chains.length} stack${chains.length === 1 ? "" : "s"} would be created`,
109
+ );
110
+ }
111
+
112
+ // Report forks
113
+ const forkPoints = untracked.filter((b) => {
114
+ const children = untracked.filter((c) => childOf.get(c) === b);
115
+ return children.length > 1;
116
+ });
117
+ if (forkPoints.length > 0) {
118
+ yield* Console.log("\nNote: forked branches detected (not supported yet):");
119
+ for (const branch of forkPoints) {
120
+ const children = untracked.filter((c) => childOf.get(c) === branch);
121
+ yield* Console.log(` ${branch} → ${children.join(", ")}`);
122
+ }
123
+ }
124
+ }),
125
+ ),
126
+ );
@@ -2,15 +2,17 @@ import { Command } from "effect/unstable/cli";
2
2
  import { trunk } from "./trunk.js";
3
3
  import { create } from "./create.js";
4
4
  import { list } from "./list.js";
5
+ import { stacks } from "./stacks.js";
5
6
  import { checkout } from "./checkout.js";
6
7
  import { top } from "./top.js";
7
8
  import { bottom } from "./bottom.js";
8
9
  import { sync } from "./sync.js";
9
- import { restack } from "./restack.js";
10
10
  import { deleteCmd } from "./delete.js";
11
11
  import { submit } from "./submit.js";
12
12
  import { adopt } from "./adopt.js";
13
13
  import { log } from "./log.js";
14
+ import { clean } from "./clean.js";
15
+ import { detect } from "./detect.js";
14
16
 
15
17
  const root = Command.make("stacked").pipe(
16
18
  Command.withDescription("Branch-based stacked PR manager"),
@@ -21,14 +23,16 @@ export const command = root.pipe(
21
23
  trunk,
22
24
  create,
23
25
  list,
26
+ stacks,
24
27
  checkout,
25
28
  top,
26
29
  bottom,
27
30
  sync,
28
- restack,
29
31
  deleteCmd,
30
32
  submit,
31
33
  adopt,
32
34
  log,
35
+ clean,
36
+ detect,
33
37
  ]),
34
38
  );
@@ -1,47 +1,73 @@
1
- import { Command } from "effect/unstable/cli";
2
- import { Console, Effect } from "effect";
1
+ import { Argument, Command } from "effect/unstable/cli";
2
+ import { Console, Effect, Option } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
+ import { GitHubService } from "../services/GitHub.js";
4
5
  import { StackService } from "../services/Stack.js";
5
6
 
6
- export const list = Command.make("list").pipe(
7
- Command.withDescription("Show current stack with branch status"),
8
- Command.withHandler(() =>
7
+ const stackNameArg = Argument.string("stack").pipe(Argument.optional);
8
+
9
+ export const list = Command.make("list", { stackName: stackNameArg }).pipe(
10
+ Command.withDescription("Show stack branches (defaults to current stack)"),
11
+ Command.withHandler(({ stackName }) =>
9
12
  Effect.gen(function* () {
10
13
  const git = yield* GitService;
14
+ const gh = yield* GitHubService;
11
15
  const stacks = yield* StackService;
12
16
 
13
17
  const currentBranch = yield* git.currentBranch();
14
18
  const data = yield* stacks.load();
15
19
  const trunk = data.trunk;
16
20
 
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;
21
+ let targetStackName: string | null = null;
22
+ let targetStack: { readonly branches: readonly string[] } | null = null;
23
+
24
+ if (Option.isSome(stackName)) {
25
+ const s = data.stacks[stackName.value];
26
+ if (s === undefined) {
27
+ yield* Console.error(`Stack "${stackName.value}" not found`);
28
+ return;
29
+ }
30
+ targetStackName = stackName.value;
31
+ targetStack = s;
32
+ } else {
33
+ for (const [name, stack] of Object.entries(data.stacks)) {
34
+ if (stack.branches.includes(currentBranch)) {
35
+ targetStackName = name;
36
+ targetStack = stack;
37
+ break;
38
+ }
22
39
  }
23
40
  }
24
41
 
25
- if (currentStackName === null) {
42
+ if (targetStackName === null || targetStack === null) {
26
43
  yield* Console.log("Not on a stacked branch");
27
44
  return;
28
45
  }
29
46
 
30
- const stack = data.stacks[currentStackName];
31
- if (stack === undefined) return;
32
47
  const lines: string[] = [];
33
48
 
34
- lines.push(`Stack: ${currentStackName}`);
49
+ lines.push(`Stack: ${targetStackName}`);
35
50
  lines.push(`Trunk: ${trunk}`);
36
51
  lines.push("");
37
52
 
38
- for (let i = stack.branches.length - 1; i >= 0; i--) {
39
- const branch = stack.branches[i];
53
+ for (let i = targetStack.branches.length - 1; i >= 0; i--) {
54
+ const branch = targetStack.branches[i];
40
55
  if (branch === undefined) continue;
41
56
  const isCurrent = branch === currentBranch;
42
57
  const marker = isCurrent ? "* " : " ";
43
58
  const prefix = i === 0 ? "└─" : "├─";
44
- lines.push(`${marker}${prefix} ${branch}`);
59
+
60
+ const pr = yield* gh.getPR(branch).pipe(Effect.catch(() => Effect.succeed(null)));
61
+ const status =
62
+ pr === null
63
+ ? ""
64
+ : pr.state === "MERGED"
65
+ ? " [merged]"
66
+ : pr.state === "CLOSED"
67
+ ? " [closed]"
68
+ : ` [#${pr.number}]`;
69
+
70
+ lines.push(`${marker}${prefix} ${branch}${status}`);
45
71
  }
46
72
 
47
73
  yield* Console.log(lines.join("\n"));
@@ -0,0 +1,33 @@
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 stacks = Command.make("stacks").pipe(
7
+ Command.withDescription("List all stacks in the repo"),
8
+ Command.withHandler(() =>
9
+ Effect.gen(function* () {
10
+ const git = yield* GitService;
11
+ const stackService = yield* StackService;
12
+
13
+ const currentBranch = yield* git.currentBranch();
14
+ const data = yield* stackService.load();
15
+
16
+ const entries = Object.entries(data.stacks);
17
+ if (entries.length === 0) {
18
+ yield* Console.log("No stacks");
19
+ return;
20
+ }
21
+
22
+ const lines: string[] = [];
23
+ for (const [name, stack] of Object.entries(data.stacks)) {
24
+ const isCurrent = stack.branches.includes(currentBranch);
25
+ const marker = isCurrent ? "* " : " ";
26
+ const count = stack.branches.length;
27
+ lines.push(`${marker}${name} (${count} branch${count === 1 ? "" : "es"})`);
28
+ }
29
+
30
+ yield* Console.log(lines.join("\n"));
31
+ }),
32
+ ),
33
+ );
@@ -4,10 +4,11 @@ import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
5
 
6
6
  const trunkFlag = Flag.string("trunk").pipe(Flag.optional, Flag.withAlias("t"));
7
+ const fromFlag = Flag.string("from").pipe(Flag.optional, Flag.withAlias("f"));
7
8
 
8
- export const sync = Command.make("sync", { trunk: trunkFlag }).pipe(
9
- Command.withDescription("Rebase entire stack on latest trunk"),
10
- Command.withHandler(({ trunk: trunkOpt }) =>
9
+ export const sync = Command.make("sync", { trunk: trunkFlag, from: fromFlag }).pipe(
10
+ Command.withDescription("Fetch and rebase stack on trunk. Use --from to start from a branch."),
11
+ Command.withHandler(({ trunk: trunkOpt, from: fromOpt }) =>
11
12
  Effect.gen(function* () {
12
13
  const git = yield* GitService;
13
14
  const stacks = yield* StackService;
@@ -25,8 +26,19 @@ export const sync = Command.make("sync", { trunk: trunkFlag }).pipe(
25
26
  }
26
27
 
27
28
  const { branches } = result.stack;
29
+ const fromBranch = Option.isSome(fromOpt) ? fromOpt.value : undefined;
30
+
31
+ let startIdx = 0;
32
+ if (fromBranch !== undefined) {
33
+ const idx = branches.indexOf(fromBranch);
34
+ if (idx === -1) {
35
+ yield* Console.error(`Branch "${fromBranch}" not found in stack`);
36
+ return;
37
+ }
38
+ startIdx = idx + 1;
39
+ }
28
40
 
29
- for (let i = 0; i < branches.length; i++) {
41
+ for (let i = startIdx; i < branches.length; i++) {
30
42
  const branch = branches[i];
31
43
  if (branch === undefined) continue;
32
44
  const base = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
@@ -5,6 +5,7 @@ export class GitService extends ServiceMap.Service<
5
5
  GitService,
6
6
  {
7
7
  readonly currentBranch: () => Effect.Effect<string, GitError>;
8
+ readonly listBranches: () => Effect.Effect<string[], GitError>;
8
9
  readonly branchExists: (name: string) => Effect.Effect<boolean, GitError>;
9
10
  readonly createBranch: (name: string, from?: string) => Effect.Effect<void, GitError>;
10
11
  readonly deleteBranch: (name: string, force?: boolean) => Effect.Effect<void, GitError>;
@@ -50,6 +51,16 @@ export class GitService extends ServiceMap.Service<
50
51
  return {
51
52
  currentBranch: () => run(["rev-parse", "--abbrev-ref", "HEAD"]),
52
53
 
54
+ listBranches: () =>
55
+ run(["branch", "--format=%(refname:short)"]).pipe(
56
+ Effect.map((output) =>
57
+ output
58
+ .split("\n")
59
+ .map((b) => b.trim())
60
+ .filter((b) => b.length > 0),
61
+ ),
62
+ ),
63
+
53
64
  branchExists: (name) =>
54
65
  run(["rev-parse", "--verify", name]).pipe(
55
66
  Effect.as(true),
@@ -108,6 +119,7 @@ export class GitService extends ServiceMap.Service<
108
119
  static layerTest = (impl: Partial<ServiceMap.Service.Shape<typeof GitService>> = {}) =>
109
120
  Layer.succeed(GitService, {
110
121
  currentBranch: () => Effect.succeed("main"),
122
+ listBranches: () => Effect.succeed([]),
111
123
  branchExists: () => Effect.succeed(false),
112
124
  createBranch: () => Effect.void,
113
125
  deleteBranch: () => Effect.void,
@@ -1,40 +0,0 @@
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 restack = Command.make("restack").pipe(
7
- Command.withDescription("Rebase children after mid-stack changes"),
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 result = yield* stacks.currentStack();
15
- if (result === null) {
16
- yield* Console.error("Not on a stacked branch");
17
- return;
18
- }
19
-
20
- const { branches } = result.stack;
21
- const idx = branches.indexOf(currentBranch);
22
- if (idx === -1) {
23
- yield* Console.error("Current branch not found in stack");
24
- return;
25
- }
26
-
27
- for (let i = idx + 1; i < branches.length; i++) {
28
- const branch = branches[i];
29
- if (branch === undefined) continue;
30
- const base = branches[i - 1] ?? currentBranch;
31
- yield* Console.log(`Rebasing ${branch} onto ${base}...`);
32
- yield* git.checkout(branch);
33
- yield* git.rebase(base);
34
- }
35
-
36
- yield* git.checkout(currentBranch);
37
- yield* Console.log("Stack restacked");
38
- }),
39
- ),
40
- );