Skip to content

Commit 9348db6

Browse files
jonathanlabclaude
andcommitted
fix: sync updates PR bases, status parses isEmpty, STORIES.md cleanup
- Add updatePRBase() to GitHub class for updating PR base branches - updateStackComments() now updates PR base when stack changes after merge - STATUS_TEMPLATE now includes 'empty' field, parsed correctly - navigateTop test updated to match new behavior (creates empty @ above stack) - STORIES.md: removed .array/config.json (fully stateless), merged duplicate sections, clarified arr create requires file changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 1f9ccc0 commit 9348db6

File tree

7 files changed

+86
-74
lines changed

7 files changed

+86
-74
lines changed

.array/config.json

Lines changed: 0 additions & 4 deletions
This file was deleted.

packages/core/STORIES.md

Lines changed: 26 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ Array is a thin layer over jj. It stores almost nothing.
122122
- Track stack state (jj DAG has it)
123123
- Store enabled changesets (jj merge parents)
124124
- Store PR mappings (derived from branch names)
125+
- Store repo-level config (no `.array/` directory)
125126

126127
**Benefits:**
127128
- Can't get out of sync with jj
@@ -132,22 +133,29 @@ Array is a thin layer over jj. It stores almost nothing.
132133
**Only state Array stores:**
133134
- `~/.config/array/auth.json` — GitHub token
134135

136+
**Derived state:**
137+
138+
| Data | Source |
139+
|------|--------|
140+
| Changes, stack, DAG | jj |
141+
| Trunk branch | jj's `trunk()` revset |
142+
| PR ↔ change mapping | Derived from branch name |
143+
| Enabled changesets | jj DAG (merge parents) |
144+
135145
---
136146

137147
## Onboarding
138148

139149
### Repository Detection
140-
- When I run array in a directory with `.git`, it detects it as a git repo
141-
- When I run array in a directory without `.git`, it prompts to initialize
142-
- When I run array in a directory with `.jj`, it's ready to use
143-
- Array requires jj, that's it
150+
- When I run array in a directory with `.git` only, it prompts me to run `jj git init --colocate`
151+
- When I run array in a directory without `.git`, it prompts to run `git init && jj git init --colocate`
152+
- When I run array in a jj-colocated repo, it's ready to use (no `arr init` needed)
144153

145154
### Initialization
146155
- When I initialize git, a `.git` directory is created with an initial commit
147156
- When I initialize jj in a git repo, a `.jj` directory is created alongside `.git`
148157
- When I initialize jj, my existing git commits are imported
149-
- When I initialize jj, trunk is auto-detected (`main` or `master`) and set as `trunk()` revset
150-
- Array has no separate initialization — if jj is set up, Array works
158+
- When jj is initialized, trunk is auto-detected via jj's built-in `trunk()` revset
151159

152160
### Prerequisites
153161
- When jj is installed, I can check its version
@@ -180,19 +188,20 @@ Array is a thin layer over jj. It stores almost nothing.
180188
- Branch name format is `MM-DD-description_slug` (e.g., `12-22-add_user_auth`)
181189

182190
### Prerequisites
183-
- When @ has no changes and I create, I get an error ("No changes to save")
184191
- When I provide an empty message, I get an error
192+
- When @ has no file changes, I get an error "No changes to save"
185193

186194
### Multiple Stacks
187195
- When I create from trunk (empty @ above main), it starts a new stack
188196
- I can have multiple independent stacks off trunk
189197
- Each stack is a linear chain of changes
190198

191199
### Viewing Multiple Stacks
192-
- When I run `arr log`, I see ALL mutable stacks (not just current)
200+
- When I run `arr log`, I see only mutable stacks (not just current)
193201
- Each stack is visually separated with branch prefixes
194202
- I can see which stack I'm currently in (marked with ◉)
195-
- Immutable/orphaned branches are NOT shown (they're historical artifacts)
203+
- Immutable commits (pushed to remote) are NOT shown - use `jj log` to see them
204+
- Remote-tracking branches that have been merged/deleted are filtered out via `mutable()` revset
196205

197206
### Switching Between Stacks
198207
- When I run `arr checkout` with a change from another stack, I switch to that stack
@@ -212,7 +221,8 @@ Array is a thin layer over jj. It stores almost nothing.
212221
- When my change has multiple children and I go up, I get an error (ambiguous)
213222

214223
### Jumping to Ends
215-
- When I go to top, I get an empty @ above the topmost change (ready for new work)
224+
- When I go to top and @ is already empty/undescribed with no children, I stay where I am
225+
- When I go to top otherwise, I get an empty @ above the topmost change (via `jj new`)
216226
- When I go to bottom, @ becomes the first change above trunk
217227
- When my stack branches and I go to top, I get an error (ambiguous path)
218228

@@ -248,8 +258,8 @@ Array is a thin layer over jj. It stores almost nothing.
248258
- When I continue with no pending operation, I get an error
249259

250260
### Quick Amend (arr modify)
251-
- When I run `arr modify`, my edits squash into @- (parent)
252-
- When @ has no changes, I get an error
261+
- When I run `arr modify`, my edits squash into @- (parent) via `jj squash`
262+
- When @ is empty AND has no description, I get an error "No changes to squash"
253263
- After modify, I'm still on empty @ (ready for more work)
254264
- Descendants of the modified change auto-rebase
255265

@@ -381,8 +391,8 @@ Array is a thin layer over jj. It stores almost nothing.
381391
### After Merge
382392
- When I sync after a PR merged, the merged branch is deleted locally
383393
- When I sync after a PR merged, my stack is rebased onto trunk
384-
- When I sync after a PR merged, remaining PRs have bases updated
385-
- When I sync after a PR merged, stack comments are updated on remaining PRs
394+
- When I sync after a PR merged, remaining PRs have their base branches updated to trunk via GitHub API
395+
- When I sync after a PR merged, stack comments are updated on remaining PRs (showing full history including merged)
386396

387397
### Branch Naming on GitHub
388398
- Branches are named `MM-DD-description_slug`
@@ -402,8 +412,8 @@ Array is a thin layer over jj. It stores almost nothing.
402412
- When I sync after a PR merged, trunk includes my merged changes
403413
- When I sync, rebasing onto new trunk makes merged changes EMPTY (no diff)
404414
- When I sync, empty changes are detected and abandoned (`jj abandon`)
405-
- When I sync, the next PR's base is updated to trunk
406-
- When I sync, stack comments are updated on remaining PRs
415+
- When I sync, the next PR's base branch is updated to trunk via GitHub API (`gh pr edit --base`)
416+
- When I sync, stack comments are updated on remaining PRs (showing full history including merged)
407417

408418
### How Merged Detection Works
409419

@@ -642,48 +652,3 @@ This is stateless. jj handles the merge. Your working directory shows the combin
642652
- Array commands are wrappers around jj, not a replacement
643653
- If array gets confused, `jj log` shows the true state
644654

645-
---
646-
647-
## Configuration
648-
649-
### Philosophy: Stateless
650-
651-
Array stores almost nothing. jj is the source of truth.
652-
653-
| Data | Source | Not stored by Array |
654-
|------|--------|---------------------|
655-
| Changes, stack, DAG | jj ||
656-
| Trunk branch | jj's `trunk()` revset ||
657-
| PR ↔ change mapping | Derived from branch name ||
658-
| Enabled changesets | jj DAG (merge parents) ||
659-
660-
**Why:** Less state = less bugs. jj's data model is battle-tested. Our JSON files are not.
661-
662-
### User Config (`~/.config/array/`)
663-
- `auth.json` — GitHub token (only required state)
664-
665-
### Repo Config
666-
- None. Array is stateless at repo level.
667-
- Trunk comes from jj's `trunk()` (auto-detected or user-configured in jj)
668-
- Branch naming is hardcoded: `MM-DD-slug`
669-
- Merge strategy defaults to squash (flag override if needed)
670-
671-
### Deriving State
672-
673-
**PR mapping:**
674-
```typescript
675-
// Branch name is deterministic from description
676-
const branch = generateBranchName(change.description, change.date);
677-
// Query GitHub for PR with that branch
678-
const pr = await github.findPRByBranch(branch);
679-
```
680-
681-
**Enabled changesets:**
682-
```
683-
@ (your working copy)
684-
├── your-stack-tip
685-
├── agent-change ← enabled = it's a merge parent of @
686-
```
687-
- Enable = add as merge parent
688-
- Disable = remove as merge parent
689-
- Query = check @'s parents

packages/core/src/github.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,31 @@ export class GitHub {
325325
}
326326
}
327327

328+
async updatePRBase(prNumber: number, newBase: string): Promise<Result<void>> {
329+
try {
330+
const result = await this.executor.execute(
331+
"gh",
332+
["pr", "edit", String(prNumber), "--base", newBase],
333+
{ cwd: this.cwd },
334+
);
335+
336+
if (result.exitCode !== 0) {
337+
return err(
338+
createError(
339+
"COMMAND_FAILED",
340+
`Failed to update PR base: ${result.stderr}`,
341+
),
342+
);
343+
}
344+
345+
return ok(undefined);
346+
} catch (e) {
347+
return err(
348+
createError("COMMAND_FAILED", `Failed to update PR base: ${e}`),
349+
);
350+
}
351+
}
352+
328353
async getPRForBranch(branchName: string): Promise<Result<PRStatus | null>> {
329354
try {
330355
const result = await this.executor.execute(

packages/core/src/jj.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export class JJ {
152152
isWorkingCopy: true,
153153
isImmutable: false,
154154
hasConflicts: parsed.value.hasConflicts,
155-
isEmpty: false,
155+
isEmpty: parsed.value.isEmpty,
156156
bookmarks: parsed.value.bookmarks,
157157
};
158158

@@ -978,11 +978,13 @@ export class JJ {
978978

979979
async getPRForBranch(
980980
branch: string,
981-
): Promise<Result<{ number: number; url: string } | null>> {
981+
): Promise<
982+
Result<{ number: number; url: string; baseRefName: string } | null>
983+
> {
982984
try {
983985
const result = await this.executor.execute(
984986
"gh",
985-
["pr", "list", "--head", branch, "--json", "number,url"],
987+
["pr", "list", "--head", branch, "--json", "number,url,baseRefName"],
986988
{ cwd: this.cwd },
987989
);
988990

@@ -995,7 +997,11 @@ export class JJ {
995997
return ok(null);
996998
}
997999

998-
return ok({ number: prs[0].number, url: prs[0].url });
1000+
return ok({
1001+
number: prs[0].number,
1002+
url: prs[0].url,
1003+
baseRefName: prs[0].baseRefName,
1004+
});
9991005
} catch {
10001006
return ok(null);
10011007
}
@@ -1288,6 +1294,8 @@ export class JJ {
12881294
changeId: string;
12891295
prNumber: number;
12901296
change: Changeset;
1297+
bookmark: string;
1298+
currentBase: string;
12911299
}> = [];
12921300

12931301
for (const change of stack) {
@@ -1301,6 +1309,8 @@ export class JJ {
13011309
changeId: change.changeId,
13021310
prNumber: prResult.value.number,
13031311
change,
1312+
bookmark,
1313+
currentBase: prResult.value.baseRefName,
13041314
});
13051315
}
13061316
}
@@ -1316,6 +1326,12 @@ export class JJ {
13161326
for (let i = 0; i < prInfos.length; i++) {
13171327
const prInfo = prInfos[i];
13181328

1329+
// Update PR base if needed (first PR should base on trunk, others on previous PR's branch)
1330+
const expectedBase = i === 0 ? this.trunk : prInfos[i - 1].bookmark;
1331+
if (prInfo.currentBase !== expectedBase) {
1332+
await github.updatePRBase(prInfo.prNumber, expectedBase);
1333+
}
1334+
13191335
const stackEntries: StackEntry[] = prInfos.map((p, idx) => {
13201336
const prStatus = statuses.get(p.prNumber);
13211337
let status: StackEntry["status"] = "waiting";

packages/core/src/parser.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export function parseStatus(stdout: string): Result<{
7878
commitId: string;
7979
description: string;
8080
hasConflicts: boolean;
81+
isEmpty: boolean;
8182
bookmarks: string[];
8283
}> {
8384
try {
@@ -88,14 +89,15 @@ export function parseStatus(stdout: string): Result<{
8889

8990
const parts = line.split("\t");
9091
// Handle case where trailing empty fields are trimmed (e.g., empty bookmarks)
91-
while (parts.length < 5) {
92+
while (parts.length < 6) {
9293
parts.push("");
9394
}
94-
if (parts.length < 5) {
95+
if (parts.length < 6) {
9596
return err(createError("PARSE_ERROR", `Invalid status line: ${line}`));
9697
}
9798

98-
const [changeId, commitId, description, conflict, bookmarksStr] = parts;
99+
const [changeId, commitId, description, conflict, empty, bookmarksStr] =
100+
parts;
99101
// Strip * suffix from bookmarks (indicates diverged from origin)
100102
const bookmarks = bookmarksStr
101103
? bookmarksStr
@@ -109,6 +111,7 @@ export function parseStatus(stdout: string): Result<{
109111
commitId,
110112
description,
111113
hasConflicts: conflict === "true",
114+
isEmpty: empty === "true",
112115
bookmarks,
113116
});
114117
} catch (e) {

packages/core/src/templates.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const STATUS_TEMPLATE = `${[
1818
"commit_id.short()",
1919
"description.first_line()",
2020
"conflict",
21+
"empty",
2122
'local_bookmarks.join(",")',
2223
].join(' ++ "\\t" ++ ')} ++ "\\n"`;
2324

packages/core/tests/integration/checkout.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,16 @@ describe("Checkout/Edit", () => {
273273
await jj.navigateDown();
274274
expect(unwrap(await jj.getWorkingCopyId())).toBe(firstId);
275275

276-
// Navigate to top - should go to Third
276+
// Navigate to top - should create empty @ above Third (ready for new work)
277277
const topResult = await jj.navigateTop();
278278
expect(topResult.ok).toBe(true);
279-
expect(unwrap(await jj.getWorkingCopyId())).toBe(thirdId);
279+
const topId = unwrap(await jj.getWorkingCopyId());
280+
expect(topId).not.toBe(thirdId); // Should be a new change, not Third
281+
282+
// Verify we're above Third (Third is our parent)
283+
const status = unwrap(await jj.status());
284+
expect(status.workingCopy.isEmpty).toBe(true);
285+
expect(status.workingCopy.parents).toContain(thirdId);
280286
});
281287

282288
it("navigates to bottom of stack", async () => {

0 commit comments

Comments
 (0)