Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,12 @@

### Avoid Barrel Files

- Do not make use of index.ts

Barrel files:

- Break tree-shaking
- Create circular dependency risks
- Create circular dependency risks
- Hide the true source of imports
- Make refactoring harder

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

### CLI Package (packages/cli)

- **Dumb shell, imperative core**: CLI commands should be thin wrappers that call `@array/core`
- All business logic belongs in `@array/core`, not in CLI command files
- CLI only handles: argument parsing, calling core, formatting output
- No data transformation, tree building, or complex logic in CLI

### Core Package (packages/core)

- Shared business logic for jj/GitHub operations

## Key Libraries

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

## Testing

- Tests use vitest with jsdom environment
- Test helpers in `src/test/`
- Run specific test: `pnpm --filter array test -- path/to/test`
- `pnpm test` - Run tests across all packages
- Array app: Vitest with jsdom, helpers in `apps/array/src/test/`
11 changes: 11 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,22 @@
"node-addon-api"
]
},
"apps/cli": {
"entry": ["src/cli.ts"],
"project": ["src/**/*.ts", "bin/**/*.ts"],
"includeEntryExports": true
},
"packages/agent": {
"project": ["src/**/*.ts"],
"ignore": ["src/templates/**"],
"ignoreDependencies": ["minimatch"],
"includeEntryExports": true
},
"packages/core": {
"entry": ["src/*.ts"],
"project": ["src/**/*.ts"],
"ignore": ["tests/**"],
"includeEntryExports": true
}
}
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
"typecheck": "turbo typecheck",
"lint": "biome check --write --unsafe",
"format": "biome format --write",
"test": "pnpm -r test",
"test": "turbo test",
"test:bun": "turbo test --filter=@array/core --filter=@array/cli",
"test:vitest": "pnpm --filter array --filter @posthog/electron-trpc test",
"clean": "pnpm -r clean",
"knip": "knip",
"prepare": "husky"
Expand Down
34 changes: 34 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@array/core",
"version": "0.0.1",
"description": "Changeset management on top of jj (Jujutsu VCS)",
"type": "module",
"exports": {
"./commands/*": "./src/commands/*.ts",
"./engine": "./src/engine/index.ts",
"./git/*": "./src/git/*.ts",
"./jj": "./src/jj/index.ts",
"./jj/*": "./src/jj/*.ts",
"./stacks": "./src/stacks/index.ts",
"./stacks/*": "./src/stacks/*.ts",
"./*": "./src/*.ts"
},
"scripts": {
"build": "echo 'No build needed - using TypeScript sources directly'",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.5.0"
},
"dependencies": {
"@octokit/graphql": "^9.0.3",
"@octokit/graphql-schema": "^15.26.1",
"@octokit/rest": "^22.0.1",
"zod": "^3.24.1"
},
"files": [
"dist/**/*",
"src/**/*"
]
}
106 changes: 106 additions & 0 deletions packages/core/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import { type CommandExecutor, shellExecutor } from "./executor";
import { createError, err, ok, type Result } from "./result";

const AuthStateSchema = z.object({
version: z.literal(1),
ghAuthenticated: z.boolean(),
username: z.string().optional(),
});

type AuthState = z.infer<typeof AuthStateSchema>;

const AUTH_CONFIG_DIR = ".config/array";
const AUTH_FILE = "auth.json";

function getAuthPath(): string {
return join(homedir(), AUTH_CONFIG_DIR, AUTH_FILE);
}

export async function saveAuthState(state: AuthState): Promise<void> {
const authDir = join(homedir(), AUTH_CONFIG_DIR);
const authPath = getAuthPath();

await ensureDir(authDir);
await Bun.write(authPath, JSON.stringify(state, null, 2));
}

interface GhAuthStatus {
authenticated: boolean;
username?: string;
error?: string;
}

export async function checkGhAuth(
executor: CommandExecutor = shellExecutor,
): Promise<GhAuthStatus> {
try {
const result = await executor.execute("gh", ["auth", "status"], {
cwd: process.cwd(),
});

if (result.exitCode === 0) {
const usernameMatch = result.stdout.match(
/Logged in to github\.com account (\S+)/,
);
const username = usernameMatch ? usernameMatch[1] : undefined;
return { authenticated: true, username };
}

return { authenticated: false, error: result.stderr };
} catch (e) {
return { authenticated: false, error: `Failed to check gh auth: ${e}` };
}
}

export async function ghAuthLogin(
executor: CommandExecutor = shellExecutor,
): Promise<Result<string>> {
try {
const result = await executor.execute("gh", ["auth", "login", "--web"], {
cwd: process.cwd(),
});

if (result.exitCode !== 0) {
return err(
createError(
"COMMAND_FAILED",
result.stderr || "Failed to authenticate with GitHub",
),
);
}

const status = await checkGhAuth(executor);
if (!status.authenticated) {
return err(createError("COMMAND_FAILED", "Authentication failed"));
}

return ok(status.username || "unknown");
} catch (e) {
return err(createError("COMMAND_FAILED", `Failed to authenticate: ${e}`));
}
}

export async function isGhInstalled(
executor: CommandExecutor = shellExecutor,
): Promise<boolean> {
try {
const result = await executor.execute("which", ["gh"], {
cwd: process.cwd(),
});
return result.exitCode === 0;
} catch {
return false;
}
}

async function ensureDir(dirPath: string): Promise<void> {
try {
const { mkdir } = await import("node:fs/promises");
await mkdir(dirPath, { recursive: true });
} catch {
// Directory might already exist
}
}
63 changes: 63 additions & 0 deletions packages/core/src/background-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { spawn } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

const RATE_LIMIT_FILE = ".git/arr-last-pr-refresh";
const RATE_LIMIT_MS = 60 * 1000; // 1 minute

/**
* Get the path to the rate limit file.
*/
function getRateLimitPath(cwd: string): string {
return join(cwd, RATE_LIMIT_FILE);
}

/**
* Check if we should refresh PR info (rate limited to once per minute).
*/
function shouldRefreshPRInfo(cwd: string): boolean {
const path = getRateLimitPath(cwd);

if (!existsSync(path)) {
return true;
}

try {
const content = readFileSync(path, "utf-8");
const lastRefresh = parseInt(content, 10);
const now = Date.now();
return now - lastRefresh > RATE_LIMIT_MS;
} catch {
return true;
}
}

/**
* Mark that we're starting a PR refresh (update the timestamp).
*/
function markPRRefreshStarted(cwd: string): void {
const path = getRateLimitPath(cwd);
writeFileSync(path, String(Date.now()));
}

/**
* Trigger background PR info refresh if rate limit allows.
* Spawns a detached process that runs `arr __refresh-pr-info`.
*/
export function triggerBackgroundRefresh(cwd: string): void {
if (!shouldRefreshPRInfo(cwd)) {
return;
}

// Mark as started before spawning to prevent race conditions
markPRRefreshStarted(cwd);

// Spawn detached process: arr __refresh-pr-info
const scriptPath = process.argv[1];
const child = spawn(process.argv[0], [scriptPath, "__refresh-pr-info"], {
cwd,
detached: true,
stdio: "ignore",
});
child.unref();
}
106 changes: 106 additions & 0 deletions packages/core/src/bookmark-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { getPRForBranch, type PRInfo } from "./github/pr-status";
import { createError, err, ok, type Result } from "./result";

/** Maximum number of suffix attempts before giving up on conflict resolution */
const MAX_BOOKMARK_SUFFIX = 25;

interface BookmarkConflictResult {
/** Original bookmark name before conflict resolution */
originalName: string;
/** Final resolved bookmark name (may have -2, -3, etc. suffix) */
resolvedName: string;
/** Whether the name was changed due to a conflict */
hadConflict: boolean;
}

/**
* Resolve bookmark name conflicts with existing closed/merged PRs on GitHub.
*
* When a bookmark name conflicts with a closed or merged PR, this function
* finds a unique name by appending -2, -3, etc. suffixes.
*
* @param bookmark - The bookmark name to check/resolve
* @param prCache - Optional pre-fetched PR cache to avoid redundant API calls
* @param assignedNames - Set of names already assigned in this batch (to avoid duplicates)
* @param cwd - Working directory (defaults to process.cwd())
* @returns The resolved bookmark name, or error if too many conflicts
*/
export async function resolveBookmarkConflict(
bookmark: string,
prCache?: Map<string, PRInfo>,
assignedNames?: Set<string>,
cwd = process.cwd(),
): Promise<Result<BookmarkConflictResult>> {
// Check cache first, otherwise fetch from GitHub
let existingPR: PRInfo | null = null;
if (prCache) {
existingPR = prCache.get(bookmark) ?? null;
} else {
const prResult = await getPRForBranch(bookmark, cwd);
if (!prResult.ok) return prResult;
existingPR = prResult.value;
}

// No conflict if PR doesn't exist or is open
if (!existingPR || existingPR.state === "OPEN") {
return ok({
originalName: bookmark,
resolvedName: bookmark,
hadConflict: false,
});
}

// PR exists and is closed/merged - find a unique suffix
const baseBookmark = bookmark;
let suffix = 2;

while (suffix <= MAX_BOOKMARK_SUFFIX) {
const candidateName = `${baseBookmark}-${suffix}`;

// Check if this candidate is already assigned in this batch
if (assignedNames?.has(candidateName)) {
suffix++;
continue;
}

// Check if this candidate has an existing PR
let candidatePR: PRInfo | null = null;
if (prCache) {
candidatePR = prCache.get(candidateName) ?? null;
} else {
const checkResult = await getPRForBranch(candidateName, cwd);
if (checkResult.ok) {
candidatePR = checkResult.value;
}
}

// Found an unused name
if (!candidatePR) {
return ok({
originalName: bookmark,
resolvedName: candidateName,
hadConflict: true,
});
}

suffix++;
}

// Exceeded max suffix attempts
return err(
createError(
"CONFLICT",
`Too many PR name conflicts for "${baseBookmark}". Clean up old PRs or use a different description.`,
),
);
}

/**
* Check if a bookmark name is a remote-tracking bookmark (e.g., "feature@origin").
*
* Remote-tracking bookmarks have a @remote suffix pattern and should be
* excluded from local operations.
*/
export function isTrackingBookmark(bookmark: string): boolean {
return /@[a-zA-Z0-9_-]+$/.test(bookmark);
}
Loading
Loading