Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,9 @@ ade lane drift resolve --lane lane-id --switch-back # put the worktree ba
ade lane drift resolve --lane lane-id --keep-head # re-point the lane (and its name) at the live HEAD branch
ade lane drift resolve --lane lane-id --keep-head --expected-head hotfix-auth --force # --expected-head guards a stale read; --force acknowledges active work
ade lanes reparent lane-child --parent lane-parent --stack-base-branch main
ade lanes reclaim-preview lane-id --text # show reclaimable space and anything that needs review
ade lanes archive-and-reclaim lane-id --confirm RECLAIM # preserve lane history/branch/chat; remove ADE-managed local files
ade lanes unarchive lane-id # restore the lane; recreate its managed worktree when needed
ade lanes delete lane-id --force --delete-branch
ade lanes create-from-linear --issue-id ENG-431 --start-chat --provider codex --model <model>
ade lanes batch-create-from-linear --linear-issues-json '[{"id":"...","identifier":"ENG-431"},{"id":"...","identifier":"ENG-440"}]'
Expand Down
2 changes: 2 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1570,6 +1570,8 @@ export async function createAdeRuntime(args: {
|| ptyService.isTranscriptPathActive(filePath)
|| Boolean(iosSimulatorService?.isBuildPathActive(filePath)),
projectId,
laneService,
projectConfigService,
// One bounded `ade_feature_used` per completed maintenance run at the daemon
// boundary (deduped to 20 h by the service).
captureAnalytics: (input) => {
Expand Down
56 changes: 56 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,62 @@ describe("ADE CLI", () => {
});
});

it("builds lane reclaim preview and confirmed reclaim commands", () => {
const preview = expectExecutePlan(
buildCliPlan(["lanes", "reclaim-preview", "lane-123"]),
);
expect(preview.label).toBe("lane reclaim preview");
expect(preview.steps).toEqual([
{
key: "result",
method: "ade/actions/call",
params: {
name: "run_ade_action",
arguments: {
domain: "lane",
action: "getReclaimRisk",
args: { laneId: "lane-123" },
},
},
unwrapToolResult: true,
},
]);

const reclaim = expectExecutePlan(
buildCliPlan([
"lanes",
"archive-and-reclaim",
"lane-123",
"--confirm",
"RECLAIM",
"--force-dirty",
]),
);
expect(reclaim.label).toBe("lane archive and reclaim");
expect(reclaim.steps).toEqual([
{
key: "result",
method: "ade/actions/call",
params: {
name: "run_ade_action",
arguments: {
domain: "lane",
action: "archiveAndReclaim",
args: {
laneId: "lane-123",
confirmation: "RECLAIM",
forceDirty: true,
},
},
},
unwrapToolResult: true,
},
]);
expect(() =>
buildCliPlan(["lanes", "archive-and-reclaim", "lane-123"]),
).toThrow(/--confirm RECLAIM/);
});

it("builds sync status and pairing PIN commands", () => {
const status = buildCliPlan([
"sync",
Expand Down
55 changes: 54 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1485,7 +1485,10 @@ const HELP_BY_COMMAND: Record<string, string> = {
Child lanes carry the parent's unmerged work
$ ade lanes import --branch <branch> Register an existing branch/worktree
$ ade lanes archive <lane> Archive a lane in ADE
$ ade lanes unarchive <lane> Restore an archived lane
$ ade lanes reclaim-preview <lane> Show reclaimable space and safety warnings
$ ade lanes archive-and-reclaim <lane> --confirm RECLAIM
Archive the lane, then remove its ADE-managed local files
$ ade lanes unarchive <lane> Restore an archived lane and recreate its worktree if needed
$ ade lanes delete <lane> --force Delete a lane and clean up its worktree
$ ade lanes attach --path <worktree> --name <n> Attach an external worktree
$ ade lanes reparent <lane> --parent <parent> Move lane onto a new parent (runs git rebase)
Expand Down Expand Up @@ -4177,6 +4180,56 @@ function buildLanePlan(args: string[]): CliPlan {
],
};
}
if (
sub === "reclaim-preview" ||
sub === "reclaim-risk" ||
sub === "preview-reclaim"
) {
const laneId = requireValue(
readLaneId(args) ?? firstPositional(args),
"laneId",
);
return {
kind: "execute",
label: "lane reclaim preview",
steps: [
actionStep(
"result",
"lane",
"getReclaimRisk",
collectGenericObjectArgs(args, { laneId }),
),
],
};
}
if (sub === "archive-and-reclaim" || sub === "reclaim") {
const laneId = requireValue(
readLaneId(args) ?? firstPositional(args),
"laneId",
);
const confirmation = readValue(args, ["--confirm", "--confirmation"]);
if (confirmation !== "RECLAIM") {
throw new CliUsageError(
'archive-and-reclaim requires --confirm RECLAIM. Run "ade lanes reclaim-preview <lane>" first.',
);
}
return {
kind: "execute",
label: "lane archive and reclaim",
steps: [
actionStep(
"result",
"lane",
"archiveAndReclaim",
collectGenericObjectArgs(args, {
laneId,
confirmation: "RECLAIM",
forceDirty: readFlag(args, ["--force-dirty"]),
}),
),
],
};
}
if (sub === "delete" || sub === "rm") {
const laneId = requireValue(
readLaneId(args) ?? firstPositional(args),
Expand Down
45 changes: 45 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ function createService(options?: {
isCloudRelayEnabled?: () => boolean;
linearCredentialService?: Record<string, unknown>;
linearOAuthService?: Record<string, unknown>;
laneEnvironmentService?: Record<string, unknown>;
portAllocationService?: Record<string, unknown>;
getLinearIssueTracker?: () => Record<string, unknown> | null;
usageTrackingService?: Record<string, unknown>;
productAnalyticsService?: Record<string, unknown>;
Expand Down Expand Up @@ -96,6 +98,8 @@ function createService(options?: {
...(options?.isCloudRelayEnabled ? { isCloudRelayEnabled: options.isCloudRelayEnabled } : {}),
...(options?.linearCredentialService ? { linearCredentialService: options.linearCredentialService } : {}),
...(options?.linearOAuthService ? { linearOAuthService: options.linearOAuthService } : {}),
...(options?.laneEnvironmentService ? { laneEnvironmentService: options.laneEnvironmentService } : {}),
...(options?.portAllocationService ? { portAllocationService: options.portAllocationService } : {}),
...(options?.getLinearIssueTracker ? { getLinearIssueTracker: options.getLinearIssueTracker } : {}),
...(options?.usageTrackingService ? { usageTrackingService: options.usageTrackingService } : {}),
...(options?.productAnalyticsService ? { productAnalyticsService: options.productAnalyticsService } : {}),
Expand Down Expand Up @@ -2468,6 +2472,47 @@ describe("lanes.suggestName", () => {
});
});

describe("lanes.unarchive", () => {
it("recreates the lane environment while preserving the mobile response", async () => {
const lane = {
id: "lane-1",
name: "Lane one",
laneType: "worktree",
worktreePath: "/repo/.ade/worktrees/lane-1",
};
const unarchive = vi.fn().mockResolvedValue({
lane,
worktreeRecreated: true,
});
const list = vi.fn().mockResolvedValue([lane]);
const envInitConfig = { dependencies: ["npm install"] };
const initLaneEnvironment = vi.fn().mockResolvedValue({ state: "ready" });
const { service } = createService({
laneService: { unarchive, list },
projectConfigService: {
getEffective: vi.fn().mockReturnValue({
laneEnvInit: null,
laneOverlayPolicies: [],
}),
},
laneEnvironmentService: {
resolveEnvInitConfig: vi.fn().mockReturnValue(envInitConfig),
initLaneEnvironment,
},
portAllocationService: {
getLease: vi.fn().mockReturnValue(null),
},
});

await expect(
service.execute(makePayload("lanes.unarchive", { laneId: "lane-1" })),
).resolves.toEqual({ ok: true });
expect(unarchive).toHaveBeenCalledWith({ laneId: "lane-1" });
expect(list).toHaveBeenCalledWith({ includeStatus: false });
expect(initLaneEnvironment).toHaveBeenCalledWith(lane, envInitConfig, {});
});
});

describe("lanes.refreshSnapshots conditional responses", () => {
function createLaneListService() {
const lanes = [{ id: "lane-1", name: "Lane one", status: { dirty: false, ahead: 0, behind: 0 } }];
Expand Down
35 changes: 31 additions & 4 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3441,6 +3441,35 @@ async function deleteLaneWithRuntimeCleanup(
return { ok: true };
}

async function unarchiveLaneWithRuntimeSetup(
args: SyncRemoteCommandServiceArgs,
payload: Record<string, unknown>,
): Promise<{ ok: true }> {
const archiveArgs = parseArchiveLaneArgs(payload, "lanes.unarchive");
const result = await args.laneService.unarchive(archiveArgs);
if (!result.worktreeRecreated || !args.laneEnvironmentService) {
return { ok: true };
}
try {
const context = await resolveLaneOverlayContext(args, archiveArgs.laneId);
if (context.envInitConfig) {
Comment thread
arul28 marked this conversation as resolved.
Outdated
await args.laneEnvironmentService.initLaneEnvironment(
context.lane,
context.envInitConfig,
context.overrides,
);
}
} catch (error) {
// Keep the established mobile command response stable. The worktree was
// restored successfully; environment setup can be retried separately.
args.logger.warn("sync_remote.lane_env_setup.post_unarchive_failed", {
laneId: archiveArgs.laneId,
err: String(error),
});
}
return { ok: true };
}

async function resolveChatCreateArgs<T extends AgentChatCreateArgs>(
service: ReturnType<typeof createAgentChatService>,
payload: T,
Expand Down Expand Up @@ -3801,10 +3830,8 @@ function registerLaneRemoteCommands({ args, register }: RemoteCommandRegistratio
await args.laneService.archive(parseArchiveLaneArgs(payload, "lanes.archive"));
return { ok: true };
});
register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => {
await args.laneService.unarchive(parseArchiveLaneArgs(payload, "lanes.unarchive"));
return { ok: true };
});
register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) =>
unarchiveLaneWithRuntimeSetup(args, payload));
register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) =>
deleteLaneWithRuntimeCleanup(args, payload));
register("lanes.getStackChain", { viewerAllowed: true }, async (payload) =>
Expand Down
36 changes: 36 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
formatGoalBannerLine,
formatGitConflictReport,
formatLaneDeleteRisk,
formatLaneReclaimPreview,
formFieldUsesPromptInput,
isChatFlushEdge,
isChatSessionAnimating,
Expand Down Expand Up @@ -1563,6 +1564,41 @@ describe("formatLaneDeleteRisk", () => {
});
});

describe("formatLaneReclaimPreview", () => {
it("states what ADE removes, keeps, and requires before reclaiming dirty work", () => {
const preview = formatLaneReclaimPreview({
laneId: "lane-1",
laneName: "Feature lane",
branchRef: "feat/x",
worktreePath: "/project/.ade/worktrees/feature",
dirty: true,
hasUnpushedCommits: false,
unpushedCommitCount: 0,
remoteBranchExists: false,
activeChatCount: 0,
activePtyCount: 0,
activeWatcherCount: 0,
envInitialized: false,
worktreeBytes: 1024 ** 3,
generatedBytes: 256 * 1024 ** 2,
reclaimableBytes: 1.25 * 1024 ** 3,
worktreeAvailable: true,
blockedReasons: [{
code: "dirty_worktree",
message: "This lane has uncommitted files.",
disposition: "confirmation_required",
}],
lastFailure: null,
retryCount: 0,
});

expect(preview).toContain("Estimated space: 1.3 GB");
expect(preview).toContain("Keeps: the lane, branch, chats, and metadata.");
expect(preview).toContain("/lane archive-and-reclaim lane-1 RECLAIM force-dirty");
expect(preview).toContain("Nothing has been removed.");
});
});

describe("model picker escape handling", () => {
const picker = {
kind: "model-picker" as const,
Expand Down
18 changes: 17 additions & 1 deletion apps/ade-cli/src/tuiClient/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,26 @@ describe("commands", () => {
expect(unarchive?.name).toBe("/lane unarchive");
expect(unarchive?.args).toBe("feat/x");

const preview = parseCommand("/lane reclaim-preview feat/x");
expect(preview?.name).toBe("/lane reclaim-preview");
expect(preview?.args).toBe("feat/x");

const reclaim = parseCommand("/lane archive-and-reclaim feat/x RECLAIM");
expect(reclaim?.name).toBe("/lane archive-and-reclaim");
expect(reclaim?.args).toBe("feat/x RECLAIM");

// /lane delete must still match (longest-name-first ordering).
expect(parseCommand("/lane delete")?.name).toBe("/lane delete");
expect(paletteCommands("/lane").map((c) => c.name)).toEqual(
expect.arrayContaining(["/lane rename", "/lane archive", "/lane unarchive", "/lane archived", "/lane delete"]),
expect.arrayContaining([
"/lane rename",
"/lane archive",
"/lane reclaim-preview",
"/lane archive-and-reclaim",
"/lane unarchive",
"/lane archived",
"/lane delete",
]),
);
});

Expand Down
Loading