@cvr/stacked 0.2.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
@@ -53,6 +53,10 @@ stacked adopt existing-branch --after feat-auth
53
53
  # View commits per branch
54
54
  stacked log
55
55
 
56
+ # Detect existing branch chains and register as stacks
57
+ stacked detect
58
+ stacked detect --dry-run
59
+
56
60
  # Remove merged branches from stacks
57
61
  stacked clean
58
62
  stacked clean --dry-run
@@ -73,6 +77,7 @@ stacked delete feat-auth-ui
73
77
  | `top` | Jump to top of stack |
74
78
  | `bottom` | Jump to bottom of stack |
75
79
  | `sync` | Fetch + rebase stack on trunk (--from to start from a branch) |
80
+ | `detect` | Detect branch chains and register as stacks (--dry-run) |
76
81
  | `clean` | Remove merged branches from stacks (--dry-run to preview) |
77
82
  | `delete <name>` | Remove branch from stack + git |
78
83
  | `submit` | Push all + create/update PRs via `gh` |
package/bin/stacked CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cvr/stacked",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cevr/stacked"
@@ -20,6 +20,8 @@ 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
  ```
@@ -36,6 +38,7 @@ What do you need?
36
38
  | `stacked top` | Jump to top of stack |
37
39
  | `stacked bottom` | Jump to bottom of stack |
38
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) |
39
42
  | `stacked clean` | Remove merged branches from stacks (--dry-run to preview) |
40
43
  | `stacked delete <name>` | Remove branch from stack + delete git branch |
41
44
  | `stacked submit` | Push all branches + create/update PRs via `gh` |
@@ -127,6 +130,17 @@ stacked adopt existing-branch # append to top
127
130
  stacked adopt existing-branch --after feat-auth # insert after specific branch
128
131
  ```
129
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
+
130
144
  ## Cleaning Up Merged Branches
131
145
 
132
146
  After PRs are merged on GitHub, clean up the local branches and stack metadata:
@@ -187,3 +201,4 @@ stacked submit
187
201
  - PRs target parent branches, not trunk — this is intentional for stacked review
188
202
  - Trunk defaults to `main` — use `stacked trunk <name>` if your default branch differs
189
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,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
+ );
@@ -12,6 +12,7 @@ import { submit } from "./submit.js";
12
12
  import { adopt } from "./adopt.js";
13
13
  import { log } from "./log.js";
14
14
  import { clean } from "./clean.js";
15
+ import { detect } from "./detect.js";
15
16
 
16
17
  const root = Command.make("stacked").pipe(
17
18
  Command.withDescription("Branch-based stacked PR manager"),
@@ -32,5 +33,6 @@ export const command = root.pipe(
32
33
  adopt,
33
34
  log,
34
35
  clean,
36
+ detect,
35
37
  ]),
36
38
  );
@@ -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,