Skip to content

Commit 72b5b0e

Browse files
committed
more bugfixes
1 parent af6cd43 commit 72b5b0e

File tree

6 files changed

+127
-80
lines changed

6 files changed

+127
-80
lines changed

apps/cli/README.md

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,36 @@ arr exit # back to git
5252
arr help --all # show all commands
5353
```
5454

55-
## Workflow
55+
## Example
5656

5757
```
58-
echo "hello" > hello.txt # no need to stage, jj tracks automatically
59-
arr create "add hello" # save as a change
60-
61-
echo "world" > world.txt
62-
arr create "add world" # save as another change
63-
64-
arr submit # push all as stacked PRs
65-
66-
arr sync # after reviews, sync with trunk
58+
$ echo "user model" >> user_model.ts
59+
$ arr create "Add user model"
60+
✓ Created add-user-model-qtrsqm
61+
62+
$ echo "user api" >> user_api.ts
63+
$ arr create "Add user API"
64+
✓ Created add-user-api-nnmzrt
65+
66+
$ arr log
67+
◉ (working copy)
68+
│ Empty
69+
○ 12-23-add-user-api nnmzrtzz (+1, 1 file)
70+
│ Not submitted
71+
○ 12-23-add-user-model qtrsqmmy (+1, 1 file)
72+
│ Not submitted
73+
○ main
74+
75+
$ arr submit
76+
Created PR #8: 12-23-add-user-model
77+
https://github.com/username/your-repo/pull/8
78+
Created PR #9: 12-23-add-user-api
79+
https://github.com/username/your-repo/pull/9
80+
81+
$ arr merge
82+
...
83+
84+
$ arr sync
6785
```
6886

6987
Each change becomes a PR. PRs are stacked so reviewers see the dependency.

apps/cli/src/cli.ts

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -161,37 +161,17 @@ async function showStatus(): Promise<void> {
161161
async function showWelcome(context: Context): Promise<void> {
162162
const { select } = await import("./utils/prompt");
163163

164-
console.log();
165-
console.log(` ${bold("Array")} ${dim("- Stacked PRs for jj")}`);
166-
console.log();
167-
168-
if (!context.jjInstalled) {
169-
console.log(` ${dim("jj is required but not installed.")}`);
170-
console.log();
171-
console.log(` Install jj first:`);
172-
console.log(` ${cyan("brew install jj")}`);
173-
console.log();
174-
console.log(` Then run ${cyan("arr init")} to get started.`);
164+
// If jj not installed or not in git repo, delegate to init for full setup flow
165+
if (!context.jjInstalled || !context.inGitRepo) {
166+
await init();
175167
return;
176168
}
177169

178-
if (!context.inGitRepo) {
179-
console.log(` ${dim("Not a git repository.")}`);
180-
console.log();
181-
182-
const choice = await select("What would you like to do?", [
183-
{ label: "Initialize git + Array here", value: "init" },
184-
{ label: "See all commands", value: "help" },
185-
]);
186-
187-
if (choice === "init") {
188-
await init();
189-
} else if (choice === "help") {
190-
printHelp();
191-
}
192-
return;
193-
}
170+
console.log();
171+
console.log(` ${bold("Array")} ${dim("- Stacked PRs for jj")}`);
172+
console.log();
194173

174+
// In git repo but jj not initialized - offer to initialize
195175
const choice = await select("Get started:", [
196176
{ label: "Initialize Array in this repo", value: "init" },
197177
{ label: "See all commands", value: "help" },

apps/cli/src/commands/merge.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,35 @@ interface MergeFlags {
1111
export async function merge(flags: MergeFlags = {}): Promise<void> {
1212
const jj = createJJ();
1313
const github = new GitHub(process.cwd());
14+
const trunk = jj.getTrunk();
1415

15-
const { workingCopy } = unwrap(await jj.status());
16+
const { workingCopy, parents } = unwrap(await jj.status());
1617

17-
if (workingCopy.bookmarks.length === 0) {
18+
let bookmarkName: string | null = null;
19+
let changeId: string | null = null;
20+
21+
if (workingCopy.bookmarks.length > 0) {
22+
bookmarkName = workingCopy.bookmarks[0];
23+
changeId = workingCopy.changeId;
24+
} else if (workingCopy.isEmpty && workingCopy.description.trim() === "") {
25+
// On empty WC, look at the parent below for a bookmark with a PR
26+
for (const parent of parents) {
27+
if (parent.bookmarks.length > 0) {
28+
bookmarkName = parent.bookmarks[0];
29+
changeId = parent.changeId;
30+
break;
31+
}
32+
}
33+
}
34+
35+
if (!bookmarkName) {
1836
console.error(
1937
formatError(
2038
"No bookmark on current change. Submit first with 'arr submit'",
2139
),
2240
);
2341
process.exit(1);
2442
}
25-
26-
const bookmarkName = workingCopy.bookmarks[0];
2743
const pr = unwrap(await github.getPRForBranch(bookmarkName));
2844

2945
if (!pr) {
@@ -45,15 +61,43 @@ export async function merge(flags: MergeFlags = {}): Promise<void> {
4561
process.exit(1);
4662
}
4763

64+
// Check if this is a stacked PR (base is not trunk)
65+
if (pr.baseRefName !== trunk) {
66+
console.error(
67+
formatError(
68+
`Cannot merge: PR #${pr.number} is stacked on ${cyan(pr.baseRefName)}`,
69+
),
70+
);
71+
console.log(
72+
dim(` Merge the base PR first, or use 'arr merge --stack' to merge all`),
73+
);
74+
process.exit(1);
75+
}
76+
4877
let method: "merge" | "squash" | "rebase" = "squash";
4978
if (flags.merge) method = "merge";
5079
if (flags.rebase) method = "rebase";
5180

5281
console.log(`Merging PR #${cyan(String(pr.number))}: ${pr.title}`);
5382

54-
unwrap(await github.mergePR(pr.number, { method }));
83+
// Merge and delete the remote branch to prevent stale references
84+
unwrap(
85+
await github.mergePR(pr.number, {
86+
method,
87+
deleteHead: true,
88+
headRef: bookmarkName,
89+
}),
90+
);
5591
console.log(formatSuccess(`Merged PR #${pr.number}`));
5692

93+
// Clean up local state: delete bookmark and abandon change
94+
if (bookmarkName) {
95+
await jj.deleteBookmark(bookmarkName);
96+
}
97+
if (changeId) {
98+
await jj.abandon(changeId);
99+
}
100+
57101
console.log(dim(" Syncing to update local state..."));
58102
unwrap(await jj.sync());
59103
console.log(formatSuccess("Done! Change has been merged and synced."));

apps/cli/src/utils/context.ts

Lines changed: 15 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,50 +4,29 @@ import {
44
checkContext as checkContextCore,
55
isContextValid,
66
} from "@array/core";
7-
import { cyan, red } from "./output";
7+
import { cyan, dim } from "./output";
88

99
export { type Context, type ContextLevel, isContextValid };
1010

1111
export function checkContext(): Promise<Context> {
1212
return checkContextCore(process.cwd());
1313
}
1414

15-
export function printContextError(context: Context, level: ContextLevel): void {
15+
export function printContextError(
16+
context: Context,
17+
_level: ContextLevel,
18+
): void {
19+
// All context errors point to arr init, which handles all setup flows
1620
console.log();
17-
1821
if (!context.jjInstalled) {
19-
console.log(
20-
` ${red("Error:")} jj (Jujutsu) is required but not installed.`,
21-
);
22-
console.log();
23-
console.log(` Install via Homebrew:`);
24-
console.log(` ${cyan("brew install jj")}`);
25-
console.log();
26-
console.log(` Or via Cargo:`);
27-
console.log(` ${cyan("cargo install jj-cli")}`);
28-
return;
29-
}
30-
31-
if (!context.inGitRepo) {
32-
console.log(` ${red("Error:")} Not in a git repository.`);
33-
console.log();
34-
console.log(` Run this command in an existing git repo, or:`);
35-
console.log(` ${cyan("git init && arr init")}`);
36-
return;
37-
}
38-
39-
if (!context.jjInitialized) {
40-
console.log(` ${red("Error:")} This repo is not using jj yet.`);
41-
console.log();
42-
console.log(` Initialize jj:`);
43-
console.log(` ${cyan("jj git init --colocate")}`);
44-
return;
45-
}
46-
47-
if (level === "array" && !context.arrayInitialized) {
48-
console.log(` ${red("Error:")} Array changesets not initialized.`);
49-
console.log();
50-
console.log(` Run ${cyan("arr init")} to enable changeset features.`);
51-
return;
22+
console.log(` ${dim("jj is required but not installed.")}`);
23+
} else if (!context.inGitRepo) {
24+
console.log(` ${dim("Not in a git repository.")}`);
25+
} else if (!context.jjInitialized) {
26+
console.log(` ${dim("This repo is not using jj yet.")}`);
27+
} else {
28+
console.log(` ${dim("Array is not initialized.")}`);
5229
}
30+
console.log();
31+
console.log(` Run ${cyan("arr init")} to get started.`);
5332
}

packages/core/src/github.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ export interface GitHubClient {
5454
): Promise<Result<Map<number, PRStatus>>>;
5555
mergePR(
5656
prNumber: number,
57-
options?: { method?: "merge" | "squash" | "rebase"; deleteHead?: boolean },
57+
options?: {
58+
method?: "merge" | "squash" | "rebase";
59+
deleteHead?: boolean;
60+
headRef?: string;
61+
},
5862
): Promise<Result<void>>;
5963
updatePR(
6064
prNumber: number,
@@ -349,7 +353,11 @@ export class GitHub implements GitHubClient {
349353

350354
async mergePR(
351355
prNumber: number,
352-
options?: { method?: "merge" | "squash" | "rebase"; deleteHead?: boolean },
356+
options?: {
357+
method?: "merge" | "squash" | "rebase";
358+
deleteHead?: boolean;
359+
headRef?: string;
360+
},
353361
): Promise<Result<void>> {
354362
const repoResult = await this.getRepoInfo();
355363
if (!repoResult.ok) return repoResult;
@@ -366,6 +374,18 @@ export class GitHub implements GitHubClient {
366374
merge_method: method,
367375
});
368376

377+
if (options?.deleteHead && options?.headRef) {
378+
try {
379+
await octokit.git.deleteRef({
380+
owner,
381+
repo,
382+
ref: `heads/${options.headRef}`,
383+
});
384+
} catch {
385+
// Branch deletion is best-effort, don't fail the merge
386+
}
387+
}
388+
369389
return ok(undefined);
370390
} catch (e) {
371391
const error = e as Error & { status?: number; message?: string };

packages/core/src/jj.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ export class JJ {
9494
);
9595
}
9696

97+
async deleteBookmark(name: string): Promise<Result<void>> {
98+
const result = await this.run(["bookmark", "delete", name]);
99+
if (!result.ok) return result;
100+
return ok(undefined);
101+
}
102+
97103
private async getNavigationResult(): Promise<Result<NavigationResult>> {
98104
const status = await this.status();
99105
if (!status.ok) return status;

0 commit comments

Comments
 (0)