Skip to content

Commit b8550de

Browse files
committed
update squash
1 parent 0a4ada4 commit b8550de

34 files changed

Lines changed: 3562 additions & 50 deletions

CLAUDE.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,12 @@
4646

4747
### Avoid Barrel Files
4848

49+
- Do not make use of index.ts
50+
4951
Barrel files:
52+
5053
- Break tree-shaking
51-
- Create circular dependency risks
54+
- Create circular dependency risks
5255
- Hide the true source of imports
5356
- Make refactoring harder
5457

@@ -74,6 +77,17 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed patterns (DI, services, tR
7477
- PostHog API integration in `posthog-api.ts`
7578
- Task execution and session management
7679

80+
### CLI Package (packages/cli)
81+
82+
- **Dumb shell, imperative core**: CLI commands should be thin wrappers that call `@array/core`
83+
- All business logic belongs in `@array/core`, not in CLI command files
84+
- CLI only handles: argument parsing, calling core, formatting output
85+
- No data transformation, tree building, or complex logic in CLI
86+
87+
### Core Package (packages/core)
88+
89+
- Shared business logic for jj/GitHub operations
90+
7791
## Key Libraries
7892

7993
- React 18, Radix UI Themes, Tailwind CSS
@@ -91,6 +105,5 @@ TODO: Update me
91105

92106
## Testing
93107

94-
- Tests use vitest with jsdom environment
95-
- Test helpers in `src/test/`
96-
- Run specific test: `pnpm --filter array test -- path/to/test`
108+
- `pnpm test` - Run tests across all packages
109+
- Array app: Vitest with jsdom, helpers in `apps/array/src/test/`

knip.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,22 @@
2626
"node-addon-api"
2727
]
2828
},
29+
"apps/cli": {
30+
"entry": ["src/cli.ts"],
31+
"project": ["src/**/*.ts", "bin/**/*.ts"],
32+
"includeEntryExports": true
33+
},
2934
"packages/agent": {
3035
"project": ["src/**/*.ts"],
3136
"ignore": ["src/templates/**"],
3237
"ignoreDependencies": ["minimatch"],
3338
"includeEntryExports": true
39+
},
40+
"packages/core": {
41+
"entry": ["src/*.ts"],
42+
"project": ["src/**/*.ts"],
43+
"ignore": ["tests/**"],
44+
"includeEntryExports": true
3445
}
3546
}
3647
}

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
"typecheck": "turbo typecheck",
1818
"lint": "biome check --write --unsafe",
1919
"format": "biome format --write",
20-
"test": "pnpm -r test",
20+
"test": "turbo test",
21+
"test:bun": "turbo test --filter=@array/core --filter=@array/cli",
22+
"test:vitest": "pnpm --filter array --filter @posthog/electron-trpc test",
2123
"clean": "pnpm -r clean",
2224
"knip": "knip",
2325
"prepare": "husky"

packages/core/package.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "@array/core",
3+
"version": "0.0.1",
4+
"description": "Changeset management on top of jj (Jujutsu VCS)",
5+
"type": "module",
6+
"exports": {
7+
"./commands/*": "./src/commands/*.ts",
8+
"./engine": "./src/engine/index.ts",
9+
"./git/*": "./src/git/*.ts",
10+
"./jj": "./src/jj/index.ts",
11+
"./jj/*": "./src/jj/*.ts",
12+
"./stacks": "./src/stacks/index.ts",
13+
"./stacks/*": "./src/stacks/*.ts",
14+
"./*": "./src/*.ts"
15+
},
16+
"scripts": {
17+
"build": "echo 'No build needed - using TypeScript sources directly'",
18+
"typecheck": "tsc --noEmit"
19+
},
20+
"devDependencies": {
21+
"@types/bun": "latest",
22+
"typescript": "^5.5.0"
23+
},
24+
"dependencies": {
25+
"@octokit/graphql": "^9.0.3",
26+
"@octokit/graphql-schema": "^15.26.1",
27+
"@octokit/rest": "^22.0.1",
28+
"zod": "^3.24.1"
29+
},
30+
"files": [
31+
"dist/**/*",
32+
"src/**/*"
33+
]
34+
}

packages/core/src/auth.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { homedir } from "node:os";
2+
import { join } from "node:path";
3+
import { z } from "zod";
4+
import { type CommandExecutor, shellExecutor } from "./executor";
5+
import { createError, err, ok, type Result } from "./result";
6+
7+
const AuthStateSchema = z.object({
8+
version: z.literal(1),
9+
ghAuthenticated: z.boolean(),
10+
username: z.string().optional(),
11+
});
12+
13+
type AuthState = z.infer<typeof AuthStateSchema>;
14+
15+
const AUTH_CONFIG_DIR = ".config/array";
16+
const AUTH_FILE = "auth.json";
17+
18+
function getAuthPath(): string {
19+
return join(homedir(), AUTH_CONFIG_DIR, AUTH_FILE);
20+
}
21+
22+
export async function saveAuthState(state: AuthState): Promise<void> {
23+
const authDir = join(homedir(), AUTH_CONFIG_DIR);
24+
const authPath = getAuthPath();
25+
26+
await ensureDir(authDir);
27+
await Bun.write(authPath, JSON.stringify(state, null, 2));
28+
}
29+
30+
interface GhAuthStatus {
31+
authenticated: boolean;
32+
username?: string;
33+
error?: string;
34+
}
35+
36+
export async function checkGhAuth(
37+
executor: CommandExecutor = shellExecutor,
38+
): Promise<GhAuthStatus> {
39+
try {
40+
const result = await executor.execute("gh", ["auth", "status"], {
41+
cwd: process.cwd(),
42+
});
43+
44+
if (result.exitCode === 0) {
45+
const usernameMatch = result.stdout.match(
46+
/Logged in to github\.com account (\S+)/,
47+
);
48+
const username = usernameMatch ? usernameMatch[1] : undefined;
49+
return { authenticated: true, username };
50+
}
51+
52+
return { authenticated: false, error: result.stderr };
53+
} catch (e) {
54+
return { authenticated: false, error: `Failed to check gh auth: ${e}` };
55+
}
56+
}
57+
58+
export async function ghAuthLogin(
59+
executor: CommandExecutor = shellExecutor,
60+
): Promise<Result<string>> {
61+
try {
62+
const result = await executor.execute("gh", ["auth", "login", "--web"], {
63+
cwd: process.cwd(),
64+
});
65+
66+
if (result.exitCode !== 0) {
67+
return err(
68+
createError(
69+
"COMMAND_FAILED",
70+
result.stderr || "Failed to authenticate with GitHub",
71+
),
72+
);
73+
}
74+
75+
const status = await checkGhAuth(executor);
76+
if (!status.authenticated) {
77+
return err(createError("COMMAND_FAILED", "Authentication failed"));
78+
}
79+
80+
return ok(status.username || "unknown");
81+
} catch (e) {
82+
return err(createError("COMMAND_FAILED", `Failed to authenticate: ${e}`));
83+
}
84+
}
85+
86+
export async function isGhInstalled(
87+
executor: CommandExecutor = shellExecutor,
88+
): Promise<boolean> {
89+
try {
90+
const result = await executor.execute("which", ["gh"], {
91+
cwd: process.cwd(),
92+
});
93+
return result.exitCode === 0;
94+
} catch {
95+
return false;
96+
}
97+
}
98+
99+
async function ensureDir(dirPath: string): Promise<void> {
100+
try {
101+
const { mkdir } = await import("node:fs/promises");
102+
await mkdir(dirPath, { recursive: true });
103+
} catch {
104+
// Directory might already exist
105+
}
106+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { spawn } from "node:child_process";
2+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
5+
const RATE_LIMIT_FILE = ".git/arr-last-pr-refresh";
6+
const RATE_LIMIT_MS = 60 * 1000; // 1 minute
7+
8+
/**
9+
* Get the path to the rate limit file.
10+
*/
11+
function getRateLimitPath(cwd: string): string {
12+
return join(cwd, RATE_LIMIT_FILE);
13+
}
14+
15+
/**
16+
* Check if we should refresh PR info (rate limited to once per minute).
17+
*/
18+
function shouldRefreshPRInfo(cwd: string): boolean {
19+
const path = getRateLimitPath(cwd);
20+
21+
if (!existsSync(path)) {
22+
return true;
23+
}
24+
25+
try {
26+
const content = readFileSync(path, "utf-8");
27+
const lastRefresh = parseInt(content, 10);
28+
const now = Date.now();
29+
return now - lastRefresh > RATE_LIMIT_MS;
30+
} catch {
31+
return true;
32+
}
33+
}
34+
35+
/**
36+
* Mark that we're starting a PR refresh (update the timestamp).
37+
*/
38+
function markPRRefreshStarted(cwd: string): void {
39+
const path = getRateLimitPath(cwd);
40+
writeFileSync(path, String(Date.now()));
41+
}
42+
43+
/**
44+
* Trigger background PR info refresh if rate limit allows.
45+
* Spawns a detached process that runs `arr __refresh-pr-info`.
46+
*/
47+
export function triggerBackgroundRefresh(cwd: string): void {
48+
if (!shouldRefreshPRInfo(cwd)) {
49+
return;
50+
}
51+
52+
// Mark as started before spawning to prevent race conditions
53+
markPRRefreshStarted(cwd);
54+
55+
// Spawn detached process: arr __refresh-pr-info
56+
const scriptPath = process.argv[1];
57+
const child = spawn(process.argv[0], [scriptPath, "__refresh-pr-info"], {
58+
cwd,
59+
detached: true,
60+
stdio: "ignore",
61+
});
62+
child.unref();
63+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { getPRForBranch, type PRInfo } from "./github/pr-status";
2+
import { createError, err, ok, type Result } from "./result";
3+
4+
/** Maximum number of suffix attempts before giving up on conflict resolution */
5+
const MAX_BOOKMARK_SUFFIX = 25;
6+
7+
interface BookmarkConflictResult {
8+
/** Original bookmark name before conflict resolution */
9+
originalName: string;
10+
/** Final resolved bookmark name (may have -2, -3, etc. suffix) */
11+
resolvedName: string;
12+
/** Whether the name was changed due to a conflict */
13+
hadConflict: boolean;
14+
}
15+
16+
/**
17+
* Resolve bookmark name conflicts with existing closed/merged PRs on GitHub.
18+
*
19+
* When a bookmark name conflicts with a closed or merged PR, this function
20+
* finds a unique name by appending -2, -3, etc. suffixes.
21+
*
22+
* @param bookmark - The bookmark name to check/resolve
23+
* @param prCache - Optional pre-fetched PR cache to avoid redundant API calls
24+
* @param assignedNames - Set of names already assigned in this batch (to avoid duplicates)
25+
* @param cwd - Working directory (defaults to process.cwd())
26+
* @returns The resolved bookmark name, or error if too many conflicts
27+
*/
28+
export async function resolveBookmarkConflict(
29+
bookmark: string,
30+
prCache?: Map<string, PRInfo>,
31+
assignedNames?: Set<string>,
32+
cwd = process.cwd(),
33+
): Promise<Result<BookmarkConflictResult>> {
34+
// Check cache first, otherwise fetch from GitHub
35+
let existingPR: PRInfo | null = null;
36+
if (prCache) {
37+
existingPR = prCache.get(bookmark) ?? null;
38+
} else {
39+
const prResult = await getPRForBranch(bookmark, cwd);
40+
if (!prResult.ok) return prResult;
41+
existingPR = prResult.value;
42+
}
43+
44+
// No conflict if PR doesn't exist or is open
45+
if (!existingPR || existingPR.state === "OPEN") {
46+
return ok({
47+
originalName: bookmark,
48+
resolvedName: bookmark,
49+
hadConflict: false,
50+
});
51+
}
52+
53+
// PR exists and is closed/merged - find a unique suffix
54+
const baseBookmark = bookmark;
55+
let suffix = 2;
56+
57+
while (suffix <= MAX_BOOKMARK_SUFFIX) {
58+
const candidateName = `${baseBookmark}-${suffix}`;
59+
60+
// Check if this candidate is already assigned in this batch
61+
if (assignedNames?.has(candidateName)) {
62+
suffix++;
63+
continue;
64+
}
65+
66+
// Check if this candidate has an existing PR
67+
let candidatePR: PRInfo | null = null;
68+
if (prCache) {
69+
candidatePR = prCache.get(candidateName) ?? null;
70+
} else {
71+
const checkResult = await getPRForBranch(candidateName, cwd);
72+
if (checkResult.ok) {
73+
candidatePR = checkResult.value;
74+
}
75+
}
76+
77+
// Found an unused name
78+
if (!candidatePR) {
79+
return ok({
80+
originalName: bookmark,
81+
resolvedName: candidateName,
82+
hadConflict: true,
83+
});
84+
}
85+
86+
suffix++;
87+
}
88+
89+
// Exceeded max suffix attempts
90+
return err(
91+
createError(
92+
"CONFLICT",
93+
`Too many PR name conflicts for "${baseBookmark}". Clean up old PRs or use a different description.`,
94+
),
95+
);
96+
}
97+
98+
/**
99+
* Check if a bookmark name is a remote-tracking bookmark (e.g., "feature@origin").
100+
*
101+
* Remote-tracking bookmarks have a @remote suffix pattern and should be
102+
* excluded from local operations.
103+
*/
104+
export function isTrackingBookmark(bookmark: string): boolean {
105+
return /@[a-zA-Z0-9_-]+$/.test(bookmark);
106+
}

0 commit comments

Comments
 (0)