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
5 changes: 5 additions & 0 deletions .changeset/leaner-stack-operations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kitlangton/stack": patch
---

Allow `stack skill` to print instructions outside a Git repository. Avoid scanning unrelated worktree contents during branch-specific Git operations, read state files without a separate existence check, and read independent local status information concurrently. Preserve dirty-worktree preflight and undo checkpoint behavior while simplifying internal bookkeeping and shared GitLab model handling.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
"lint": "oxlint",
"package:smoke": "bun run build && npm pack --dry-run",
"package:smoke": "npm pack --dry-run",
"prepack": "bun run build",
"release": "bun run package:smoke && npm exec --package @changesets/cli@2.31.0 -- changeset publish",
"typecheck": "tsc --noEmit",
Expand Down
7 changes: 4 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,10 @@ if (import.meta.main) {
.slice(2)
.some((arg) => arg === "--help" || arg === "-h" || arg === "--version");

const app = help
? runCli(process.argv.slice(2)).pipe(Effect.provide(docs))
: runCli(process.argv.slice(2)).pipe(Effect.provide(live));
const app =
help || process.argv[2] === "skill"
? runCli(process.argv.slice(2)).pipe(Effect.provide(docs))
: runCli(process.argv.slice(2)).pipe(Effect.provide(live));

const main = pipe(
app,
Expand Down
16 changes: 7 additions & 9 deletions src/services/Git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export const live = Layer.effect(
),
);

const worktrees = Effect.fn("Git.worktrees")(function* () {
const worktrees = Effect.fn("Git.worktrees")(function* (branch?: string) {
const out = yield* run("git", ["worktree", "list", "--porcelain", "-z"]);
const records: Array<{
path: string;
Expand Down Expand Up @@ -127,7 +127,9 @@ export const live = Layer.effect(
if (current) records.push(current);

return yield* Effect.forEach(
records.filter((record) => !record.prunable),
records.filter(
(record) => !record.prunable && (branch === undefined || record.branch === branch),
),
(record) =>
dirtyAt(record.path).pipe(
Effect.map(
Expand Down Expand Up @@ -279,7 +281,7 @@ export const live = Layer.effect(
parent: string,
commits: ReadonlyArray<string>,
) {
const owner = (yield* worktrees()).find((worktree) => worktree.branch === branch) ?? null;
const owner = (yield* worktrees(branch))[0] ?? null;
if (owner && owner.dirty.length > 0) {
return yield* Effect.fail(checkedOutDirtyError(branch, owner));
}
Expand Down Expand Up @@ -367,9 +369,7 @@ export const live = Layer.effect(
);
const release = Effect.fn("Git.release")(function* (branch: string) {
const owner =
(yield* worktrees()).find(
(worktree) => worktree.branch === branch && worktree.path !== cfg.root,
) ?? null;
(yield* worktrees(branch)).find((worktree) => worktree.path !== cfg.root) ?? null;
if (!owner) return;
if (owner.dirty.length > 0) {
return yield* Effect.fail(releaseDirtyError(branch, owner));
Expand All @@ -378,9 +378,7 @@ export const live = Layer.effect(
});
const drop = Effect.fn("Git.drop")(function* (branch: string) {
const owner =
(yield* worktrees()).find(
(worktree) => worktree.branch === branch && worktree.path !== cfg.root,
) ?? null;
(yield* worktrees(branch)).find((worktree) => worktree.path !== cfg.root) ?? null;
if (owner) {
return yield* Effect.fail(
new ExecError(
Expand Down
79 changes: 28 additions & 51 deletions src/services/Stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,12 +420,10 @@ ${note}`;

const status: StackService["status"] = Effect.fn("Stack.status")(() =>
Effect.gen(function* () {
const [state, refs, current, remote] = yield* Effect.all([
store.read(),
git.refs(),
git.current(),
git.remote(),
]);
const [state, refs, current, remote] = yield* Effect.all(
[store.read(), git.refs(), git.current(), git.remote()],
{ concurrency: 4 },
);
const pulls = yield* codeHost.changes().pipe(
Effect.catchTags({
ExecError: () => Effect.succeed([]),
Expand Down Expand Up @@ -698,7 +696,6 @@ ${note}`;
readonly apply: boolean;
readonly saved?: Map<string, string>;
readonly journalState?: ReturnType<typeof stackState>;
readonly initialEntries?: ReadonlyArray<UndoEntry>;
readonly journalActions?: ReadonlyArray<StackResult.StackResultItem>;
readonly initialActions?: ReadonlyArray<StackResult.StackResultItem>;
readonly replayAnchors?: ReadonlyMap<string, string>;
Expand Down Expand Up @@ -754,10 +751,10 @@ ${note}`;
const tips = new Map<string, string | null>();
const prior = new Map<string, string>();
const moved = new Set<string>();
const entries: Array<UndoEntry> = Array.from(opts.initialEntries ?? []);
const entries: Array<UndoEntry> = [];
const remoteUpdates: Array<RemoteUpdate> = [];
const next: Array<StackLink> = [];
let journal = apply && (initialActions.length > 0 || entries.length > 0);
let journal = apply && initialActions.length > 0;

const headRemote = Effect.fn("Stack.repairStack.headRemote")(function* (
headRepository: string | null,
Expand Down Expand Up @@ -846,7 +843,6 @@ ${note}`;
const plannedRepairBranches = Effect.fn("Stack.repairStack.plannedRepairBranches")(
function* () {
const branches = new Set<string>();
const plannedMoved = new Set<string>();
const plannedTips = new Map<string, string | null>();

for (const link of [...state.links].sort(
Expand All @@ -867,12 +863,11 @@ ${note}`;
const drift =
replayAnchors.has(String(link.branch)) ||
parent !== link.parent ||
plannedMoved.has(parent) ||
branches.has(parent) ||
(want && (Option.isNone(have) || have.value !== want));

if (drift) {
branches.add(String(link.branch));
plannedMoved.add(String(link.branch));
}
}

Expand Down Expand Up @@ -929,8 +924,6 @@ ${note}`;
(!apply && moved.has(parent)) ||
(want && (Option.isNone(have) || have.value !== want));
const base = pr?.base ?? null;
let backup: string | null = null;
let created: number | null = null;
let num = pr?.number ?? link.pr;
const previous =
apply && !pr && link.pr
Expand Down Expand Up @@ -963,7 +956,7 @@ ${note}`;
return yield* git.novel(onto, link.branch, commits);
})
: Array<string>();
backup = `backup/stack-sync-${stamp}-${link.branch}`;
const backup = `backup/stack-sync-${stamp}-${link.branch}`;
const rebase = {
branch: String(link.branch),
parent,
Expand Down Expand Up @@ -1073,7 +1066,7 @@ ${note}`;
backup: null,
pr: now.number,
base,
created,
created: null,
}),
);
journal = true;
Expand Down Expand Up @@ -1102,18 +1095,17 @@ ${note}`;
const open = prs.get(link.branch) ?? null;
if (!open) {
if (apply) {
const prev = previous;
const nextPr = draft(link, parent, prev);
if (!entries.some((item) => item.branch === link.branch)) {
entries.push(
undoEntry({
branch: link.branch,
backup: null,
pr: now?.number ?? link.pr ?? null,
base,
created: null,
}),
);
const nextPr = draft(link, parent, previous);
let entry = entries.find((item) => item.branch === link.branch);
if (!entry) {
entry = undoEntry({
branch: link.branch,
backup: null,
pr: now?.number ?? link.pr ?? null,
base,
created: null,
});
entries.push(entry);
journal = true;
}
yield* step(`create ${requestLabel} for ${link.branch} -> ${parent}`);
Expand All @@ -1126,7 +1118,6 @@ ${note}`;
nextPr.labels,
headRepository,
);
created = made.number;
num = made.number;
prs.set(link.branch, made);
const createdPull = {
Expand All @@ -1135,27 +1126,14 @@ ${note}`;
pr: Number(made.number),
} satisfies RepairPlan.CreatePullPlan;
actions.push(RepairPlan.createPull(createdPull, mode));
const i = entries.findIndex((item) => item.branch === link.branch);
if (i >= 0) {
entries[i] = undoEntry({
branch: entries[i]!.branch,
backup: entries[i]!.backup,
pr: entries[i]!.pr,
base: entries[i]!.base,
created: made.number,
...(entries[i]!.pushRemotes ? { pushRemotes: entries[i]!.pushRemotes } : {}),
});
} else {
entries.push(
undoEntry({
branch: link.branch,
backup: null,
pr: now?.number ?? link.pr ?? null,
base,
created: made.number,
}),
);
}
entries[entries.indexOf(entry)] = undoEntry({
branch: entry.branch,
backup: entry.backup,
pr: entry.pr,
base: entry.base,
created: made.number,
...(entry.pushRemotes ? { pushRemotes: entry.pushRemotes } : {}),
});
journal = true;
yield* checkpoint();
} else {
Expand Down Expand Up @@ -1688,7 +1666,6 @@ ${note}`;
readonly apply?: boolean;
readonly auto?: boolean;
readonly admin?: boolean;
readonly through?: string;
},
) =>
Effect.gen(function* () {
Expand Down
16 changes: 8 additions & 8 deletions src/services/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ export class Store extends Context.Service<Store, StoreService>()("@stack/Store"

const load = <A>(file: string, miss: () => A, parse: (raw: string) => A) =>
Effect.gen(function* () {
const has = yield* fs
.exists(file)
.pipe(Effect.mapError((err) => new StateError(file, "exists", String(err))));
if (!has) return miss();

const raw = yield* fs
.readFileString(file)
.pipe(Effect.mapError((err) => new StateError(file, "read", String(err))));
const raw = yield* fs.readFileString(file).pipe(
Effect.catchIf(
(err) => err.reason._tag === "NotFound",
() => Effect.succeed(null),
),
Effect.mapError((err) => new StateError(file, "read", String(err))),
);
if (raw === null) return miss();

return yield* Effect.try({
try: () => parse(raw),
Expand Down
15 changes: 2 additions & 13 deletions src/services/code-host/GitLab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,10 @@ class MRData extends Schema.Class<MRData>("MRData")({
}) {}

class MRView extends Schema.Class<MRView>("MRView")({
iid: Schema.Number,
title: Schema.String,
...MRData.fields,
description: Schema.NullOr(Schema.String),
source_branch: Schema.String,
target_branch: Schema.String,
web_url: Schema.String,
draft: Schema.Boolean,
state: Schema.String,
labels: Schema.Array(LabelEntry),
source_project_id: Schema.NullOr(Schema.Number),
}) {}

class MRWatch extends Schema.Class<MRWatch>("MRWatch")({
Expand Down Expand Up @@ -100,14 +94,9 @@ const ref = (row: MRData, headRepository: string | null) =>

const meta = (row: MRView, headRepository: string | null) =>
pullMeta({
number: row.iid,
...ref(row, headRepository),
title: row.title,
body: row.description ?? "",
head: row.source_branch,
headRepository,
base: row.target_branch,
url: row.web_url,
draft: row.draft,
state: row.state,
labels: row.labels.map((item) => new PullLabel({ name: labelName(item) })),
});
Expand Down
47 changes: 3 additions & 44 deletions src/services/code-host/Memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,40 +70,13 @@ export const layer = (opts: Options) =>
yield* requireOpen(pr);
yield* record(`edit ${pr} ${base}`);
yield* Ref.update(pullsRef, (pulls) =>
pulls.map((item) =>
item.number === pr
? pullRef({
number: item.number,
title: item.title,
head: item.head,
headRepository: item.headRepository,
base,
url: item.url,
draft: item.draft,
checks: item.checks,
})
: item,
),
pulls.map((item) => (item.number === pr ? pullRef({ ...item, base }) : item)),
);
yield* Ref.update(metasRef, (metas) => {
const nextMetas = new Map(metas);
const current = nextMetas.get(pr);
if (current) {
nextMetas.set(
pr,
pullMeta({
number: current.number,
title: current.title,
body: current.body,
head: current.head,
headRepository: current.headRepository,
base,
url: current.url,
draft: current.draft,
state: current.state,
labels: current.labels,
}),
);
nextMetas.set(pr, pullMeta({ ...current, base }));
}
return nextMetas;
});
Expand All @@ -118,21 +91,7 @@ export const layer = (opts: Options) =>
const nextMetas = new Map(metas);
const current = nextMetas.get(pr);
if (current) {
nextMetas.set(
pr,
pullMeta({
number: current.number,
title: current.title,
body,
head: current.head,
headRepository: current.headRepository,
base: current.base,
url: current.url,
draft: current.draft,
state: current.state,
labels: current.labels,
}),
);
nextMetas.set(pr, pullMeta({ ...current, body }));
}
return nextMetas;
});
Expand Down
18 changes: 18 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { Effect, Layer } from "effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Proc from "../src/platform/proc.ts";

it.effect("skill prints the packaged instructions outside a Git repository", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const proc = yield* Proc.Service;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-skill-" });
const output = yield* proc.exec(root, "bun", [path.resolve("src/cli.ts"), "skill"]);
expect(output).toContain("name: stack");
expect(output).toContain("stack sync");
}).pipe(Effect.provide(Proc.live.pipe(Layer.provideMerge(NodeServices.layer)))),
);
Loading