Swadhin Biswas Feature/ai code reviewer (#73) · 8/2/2026
105a287
398 lines · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398

/**
 * Stack Commands
 * Manage stacked PRs from the CLI
 */

import chalk from "chalk";
import { spawnSync } from "child_process";
import { Command } from "commander";
import fs from "fs";
import ora from "ora";
import os from "os";
import path from "path";
import { simpleGit } from "simple-git";
import { applyTlsConfig } from "../../lib/api.js";
import { getConfig } from "../../lib/config.js";
import { getRepoInfoFromGit } from "../../lib/git.js";
import {
  setParentBranch,
  getParentBranch,
  getStackTopology,
  flattenStack,
  recursiveRebase
} from "../../lib/stack-manager.js";

const git = simpleGit();

export const stackCommands = new Command("stack").description(
  "Manage stacked PRs",
);

function createAskPassScript(): string {
  const scriptPath = path.join(
    os.tmpdir(),
    `och-askpass-${process.pid}-${Date.now()}.sh`,
  );

  const script = `#!/bin/sh
case "$1" in
*Username*) echo "$GIT_ASKPASS_USERNAME" ;;
*Password*) echo "$GIT_ASKPASS_PASSWORD" ;;
*) echo "$GIT_ASKPASS_PASSWORD" ;;
esac
`;

  fs.writeFileSync(scriptPath, script, { mode: 0o700 });
  return scriptPath;
}

// Create a new branch in the stack
stackCommands
  .command("create <name>")
  .description("Create a new branch in the current stack")
  .option("-m, --message <message>", "Commit message for work in progress")
  .action(async (name: string, options) => {
    const spinner = ora("Creating stacked branch...").start();

    try {
      // Get current branch
      const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();

      // Create new branch
      const newBranch = `stack/${name}`;
      await git.checkoutLocalBranch(newBranch);

      // Save parent metadata
      await setParentBranch(git, newBranch, currentBranch);

      spinner.succeed(
        `Created branch ${chalk.hex("#50fa7b")(newBranch)} (stacked on ${chalk.hex("#8be9fd")(currentBranch)})`,
      );

      console.log("\n" + chalk.hex("#6272a4")("Next steps:"));
      console.log(chalk.hex("#6272a4")("  • Make your changes"));
      console.log(
        chalk.hex("#6272a4")("  • Run ") +
        chalk.hex("#8be9fd")("och stack submit") +
        chalk.hex("#6272a4")(" to push and create PRs"),
      );
    } catch (error) {
      spinner.fail("Failed to create branch");
      console.error(
        chalk.hex("#ff5555")(error instanceof Error ? error.message : "Unknown error"),
      );
      process.exit(1);
    }
  });

// Submit stack to remote and create PRs
stackCommands
  .command("submit")
  .description("Push stack and create/update PRs for all branches")
  .option("-d, --draft", "Create PRs as drafts")
  .option("-m, --message <message>", "PR title prefix")
  .action(async (options) => {
    const config = getConfig();

    if (!config.token) {
      console.error(chalk.hex("#ff5555")("Not logged in. Run 'och auth login' first."));
      process.exit(1);
    }

    const spinner = ora("Analyzing stack...").start();

    try {
      // Get stack topology
      const roots = await getStackTopology(git);
      const allNodes = flattenStack(roots);

      // Filter for branches starting with 'stack/' or just use the topology?
      // Let's use topology but only push branches that we manage.
      // Assuming user is on a branch in the stack, we probably want to submit *this* stack.
      const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();

      // Filter nodes related to current branch? existing logic submitted ALL 'stack/' branches.
      // Let's stick to submitting topologically sorted list of 'stack/' branches.
      const sortedStackBranches = allNodes
        .map(n => n.branch)
        .filter(b => b.startsWith("stack/"));

      if (sortedStackBranches.length === 0) {
        spinner.fail("No stack branches found");
        console.log(
          chalk.hex("#6272a4")("Run ") +
          chalk.hex("#8be9fd")("och stack create <name>") +
          chalk.hex("#6272a4")(" to start a stack."),
        );
        process.exit(1);
      }

      const repoInfo = await getRepoInfoFromGit(git);
      if (!repoInfo) {
        spinner.fail("Could not determine repository from origin");
        process.exit(1);
      }

      const { owner, repo: repoName } = repoInfo;

      // Push all stack branches with authentication (no token in URL)
      spinner.text = "Pushing branches...";
      const pushedBranches: string[] = [];
      const repoRoot = (await git.revparse(["--show-toplevel"])).trim();
      const askPass = createAskPassScript();

      const pushEnv = {
        ...process.env,
        GIT_ASKPASS: askPass,
        GIT_ASKPASS_USERNAME: config.username || owner,
        GIT_ASKPASS_PASSWORD: config.token,
        GIT_TERMINAL_PROMPT: "0",
      };

      try {
        for (const branch of sortedStackBranches) {
          try {
            console.log(chalk.hex("#6272a4")(`  Pushing ${branch} to origin...`));
            const result = spawnSync(
              "git",
              ["push", "origin", branch, "--force-with-lease"],
              {
                cwd: repoRoot,
                env: pushEnv,
                stdio: "pipe",
                encoding: "utf-8",
              },
            );

            if (result.status !== 0) {
              throw new Error(result.stderr || "Push failed");
            }

            pushedBranches.push(branch);
            console.log(chalk.hex("#50fa7b")(`  ✓ Pushed ${branch}`));
          } catch (e) {
            console.log(
              chalk.hex("#ff5555")(
                `\n  ✗ Could not push ${branch}: ${e instanceof Error ? e.message : "Unknown error"}`,
              ),
            );
          }
        }
      } finally {
        fs.rmSync(askPass, { force: true });
      }

      spinner.text = "Creating PRs...";

      // Build branch metadata for PR creation
      const branchData = [];
      for (const branch of sortedStackBranches) {
        const name = branch.replace("stack/", "");
        const parent = await getParentBranch(git, branch) || "main";

        branchData.push({
          name: branch,
          title: options.message
            ? `${options.message}: ${name}`
            : `[Stack] ${name}`,
          description: `Part of stacked PR workflow.\n\nBranch: \`${branch}\`\nBased on: \`${parent}\``,
          parentBranch: parent,
        });
      }

      // Create PRs via API
      try {
        applyTlsConfig();
        const response = await fetch(`${config.serverUrl}/api/stacks`, {
          method: "POST",
          headers: {
            Authorization: `Bearer ${config.token}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            owner,
            repo: repoName,
            baseBranch: "main",
            branches: branchData,
          }),
        });

        if (!response.ok) {
          const error = await response.json();
          throw new Error(error.error || "Failed to create PRs");
        }

        const result = await response.json();

        spinner.succeed(
          `Stack submitted with ${pushedBranches.length} branches`,
        );

        if (result.data?.stack?.pullRequests) {
          console.log(chalk.bold("\n📋 Pull Requests Created:\n"));
          for (const pr of result.data.stack.pullRequests) {
            console.log(`  ${chalk.hex("#50fa7b")("●")} #${pr.number}: ${pr.title}`);
            console.log(chalk.hex("#6272a4")(`    ${pr.branch} → ${pr.baseBranch}`));
          }
        }
      } catch (apiError) {
        // API might not be available, just show success for pushing
        spinner.succeed(`Pushed ${pushedBranches.length} branches`);
        console.log(
          chalk.hex("#f1fa8c")("\n  Note: Could not auto-create PRs (API unavailable)"),
        );
        console.log(chalk.hex("#6272a4")("  Create PRs manually at your repository\n"));
      }

      console.log(chalk.hex("#6272a4")("\nNext steps:"));
      console.log(chalk.hex("#6272a4")("  • Get reviews on your PRs"));
      console.log(
        chalk.hex("#6272a4")("  • Run ") +
        chalk.hex("#8be9fd")("och stack sync") +
        chalk.hex("#6272a4")(" to keep in sync"),
      );
    } catch (error) {
      spinner.fail("Failed to submit stack");
      console.error(
        chalk.hex("#ff5555")(error instanceof Error ? error.message : "Unknown error"),
      );
      process.exit(1);
    }
  });

// View current stack
stackCommands
  .command("log")
  .alias("ls")
  .alias("view")
  .description("Visual stack graph")
  .action(async () => {
    try {
      const roots = await getStackTopology(git);
      const { printSectionHeader, formatBadge } = await import("../../lib/formatter.js");

      // Use Dracula colors directly or via chalk
      const cyan = chalk.hex("#8be9fd");
      const green = chalk.hex("#50fa7b");
      const purple = chalk.hex("#bd93f9");
      const comment = chalk.hex("#6272a4");
      const pink = chalk.hex("#ff79c6");

      printSectionHeader("Stack Topology", "Current branch and dependencies", "STACK");

      if (roots.length === 0) {
        console.log(comment("  No stack branches found.\n"));
        return;
      }

      const printNode = (node: any, prefix: string, isLast: boolean, isRoot: boolean) => {
        // Use beautiful thick unicode borders for the tree
        const marker = isRoot ? "" : (isLast ? " └── " : " ├── ");
        
        // Color coding
        const isCurrent = node.isCurrent;
        const branchColor = isCurrent ? green.bold : cyan;
        
        // Badges
        const currentBadge = isCurrent ? formatBadge("current", "success") + " " : "";
        const baseBadge = isRoot ? formatBadge("base", "accent") + " " : "";
        
        console.log(`  ${comment(prefix)}${comment(marker)}${baseBadge}${currentBadge}${branchColor(node.branch)}`);

        const newPrefix = isRoot ? "" : (prefix + (isLast ? "     " : " │   "));

        for (let i = 0; i < node.children.length; i++) {
          printNode(node.children[i], newPrefix, i === node.children.length - 1, false);
        }
      };

      for (const root of roots) {
        if (root.children.length > 0 || root.branch === 'main') {
          console.log(""); // Spacing
          printNode(root, "", true, true);
        }
      }
      console.log(""); // Bottom Spacing

    } catch (error) {
      console.error(chalk.hex("#ff5555")("Failed to view stack"), error);
      process.exit(1);
    }
  });

// Sync stack (Recursive Rebase)
stackCommands
  .command("sync")
  .description("Recursively rebase stack on updated main")
  .action(async () => {
    const spinner = ora("Syncing stack...").start();

    try {
      // 1. Update main
      spinner.text = "Fetching origin/main...";
      await git.fetch();
      try {
        await git.checkout("main");
        await git.pull();
      } catch (e) {
        // Maybe main doesn't exist locally or something
      }

      // 2. Refresh topology
      const roots = await getStackTopology(git);
      const flatStack = flattenStack(roots);

      // Filter out main itself from loop
      const stackBranches = flatStack.filter(n => n.branch !== 'main');

      if (stackBranches.length === 0) {
        spinner.info("No stack branches to sync.");
        return;
      }

      let syncedCount = 0;

      // 3. Recursive Rebase
      // We iterate breadth-first or topologically (flattenStack is DFS preorder usually, which ensures parents processed before children)
      // Actually flattenStack implementation above is DFS preorder. Parent -> Child -> ...
      for (const node of stackBranches) {
        const parentBranch = node.parent;
        if (!parentBranch) continue;

        spinner.text = `Rebasing ${node.branch} onto ${parentBranch}...`;

        try {
          // We rebase 'node.branch' onto 'parentBranch'
          // Since we processed 'parentBranch' already (if it was in stack), 'parentBranch' tip is updated.
          // So 'git rebase parentBranch' works perfectly.
          await git.checkout(node.branch);
          await git.rebase([parentBranch]);
          syncedCount++;
        } catch (e) {
          spinner.fail(`Conflict rebasing ${node.branch} onto ${parentBranch}`);
          console.log(chalk.hex("#f1fa8c")("\nResolve conflicts manually, then continue with:"));
          console.log(chalk.hex("#8be9fd")(`  git rebase --continue`));
          // We have to stop here because children depend on this
          process.exit(1);
        }
      }

      // 4. Force Push updates (Optional, or explicit?)
      // Graphite defaults to not pushing unless requested or 'gt submit'.
      // But 'och stack sync' implies syncing WITH remote often.
      // Let's keep it local only for safety, user updates remote via 'och stack submit'.

      spinner.succeed(`Recursively rebased ${syncedCount} branches. Run 'och stack submit' to push updates.`);

    } catch (error) {
      spinner.fail("Failed to sync stack");
      console.error(
        chalk.hex("#ff5555")(error instanceof Error ? error.message : "Unknown error"),
      );
      process.exit(1);
    }
  });

export default stackCommands;