Skip to content
Open
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
36 changes: 36 additions & 0 deletions packages/cli/mocks/deleted_branches/projects/test/branches/GET.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect } from 'vitest';

const activeBranch = {
id: 'br-main-branch-123456',
name: 'main',
default: true,
current_state: 'ready',
created_at: '2021-01-01T00:00:00.000Z',
updated_at: '2021-01-01T00:00:00.000Z',
};

const deletedBranch = {
id: 'br-gone-branch-123456',
name: 'gone-branch',
current_state: 'ready',
created_at: '2021-01-01T00:00:00.000Z',
updated_at: '2021-01-01T00:00:00.000Z',
recovery: {
deleted_at: '2026-06-30T00:00:00.000Z',
recoverable_until: '2026-07-07T00:00:00.000Z',
deletion_method: 'user',
},
};

export default function (req, res) {
const includeDeleted = req.query.include_deleted === 'true';
if (!includeDeleted) {
expect(req.query.include_deleted).toBe('false');
}
res.send({
annotations: {},
branches: includeDeleted
? [activeBranch, deletedBranch]
: [activeBranch],
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default function (req, res) {
res.send({
branch: {
id: 'br-gone-branch-123456',
name: 'gone-branch',
current_state: 'ready',
created_at: '2021-01-01T00:00:00.000Z',
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expect } from 'vitest';

export default function (req, res) {
expect(req.query.hard_delete).toBe('true');
res.send({
branch: {
id: 'br-harddel-branch-123456',
name: 'harddel-branch',
created_at: '2021-01-01T00:00:00.000Z',
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default function (req, res) {
res.send({
branch: {
id: 'br-sunny-branch-123456',
name: 'sunny-branch',
current_state: 'ready',
created_at: '2021-01-01T00:00:00.000Z',
},
});
}
9 changes: 9 additions & 0 deletions packages/cli/src/__snapshots__/writer.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ exports[`writer > outputs table > outputs single data with title 1`] = `
"
`;

exports[`writer > outputs table > outputs table with custom header label 1`] = `
"┌──────────────────────────┐
│ Recoverable Until │
├──────────────────────────┤
│ 2026-07-07T00:00:00.000Z │
└──────────────────────────┘
"
`;

exports[`writer > outputs table > outputs table with custom renderer 1`] = `
"T1
┌──────────────┐
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,11 +541,23 @@ export const getApiClient = ({ apiKey, apiHost }: ApiCallProps) => {
body: data,
}),
),
deleteProjectBranch: (projectId: string, branchId: string) =>
deleteProjectBranch: (
projectId: string,
branchId: string,
hardDelete?: boolean,
) =>
call(() =>
raw.deleteProjectBranch({
client,
path: { project_id: projectId, branch_id: branchId },
...(hardDelete ? { query: { hard_delete: true } } : {}),
}),
),
recoverProjectBranch: (projectId: string, branchId: string) =>
call(() =>
raw.recoverProjectBranch({
client,
path: { project_id: projectId, branch_id: branchId },
}),
),
restoreProjectBranch: (
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/commands/__snapshots__/branches.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ created_at: 2021-01-01T00:00:00.000Z
"
`;

exports[`branches > delete by id with hard-delete 1`] = `
"id: br-harddel-branch-123456
name: harddel-branch
created_at: 2021-01-01T00:00:00.000Z
"
`;

exports[`branches > get by id 1`] = `
"name: test_branch
id: br-sunny-branch-123456
Expand Down Expand Up @@ -228,6 +235,26 @@ updated_at: 2019-01-01T00:00:00Z
"
`;

exports[`branches > list with include-deleted shows deleted state and recovery 1`] = `
"┌────────────────┬───────────────────────┬───────────────┬──────────────────────────┬──────────────────────────┐
│ Name │ Id │ Current State │ Created At │ Recoverable Until │
├────────────────┼───────────────────────┼───────────────┼──────────────────────────┼──────────────────────────┤
│ [default] main │ br-main-branch-123456 │ ready │ 2021-01-01T00:00:00.000Z │ │
├────────────────┼───────────────────────┼───────────────┼──────────────────────────┼──────────────────────────┤
│ gone-branch │ br-gone-branch-123456 │ deleted │ 2021-01-01T00:00:00.000Z │ 2026-07-07T00:00:00.000Z │
└────────────────┴───────────────────────┴───────────────┴──────────────────────────┴──────────────────────────┘
"
`;

exports[`branches > list without include-deleted omits deleted branches 1`] = `
"┌────────────────┬───────────────────────┬───────────────┬──────────────────────────┐
│ Name │ Id │ Current State │ Created At │
├────────────────┼───────────────────────┼───────────────┼──────────────────────────┤
│ [default] main │ br-main-branch-123456 │ ready │ 2021-01-01T00:00:00.000Z │
└────────────────┴───────────────────────┴───────────────┴──────────────────────────┘
"
`;

exports[`branches > list/table marks the branch pinned in .neon as [current] 1`] = `
"┌──────────────────────────────┬────────────────────────────┬───────────────┬──────────────────────────┬──────────────────────────┐
│ Name │ Id │ Current State │ Created At │ Expires At │
Expand Down Expand Up @@ -308,6 +335,32 @@ exports[`branches > list/yaml 1`] = `
"
`;

exports[`branches > recover by id 1`] = `
"id: br-sunny-branch-123456
name: sunny-branch
current_state: ready
created_at: 2021-01-01T00:00:00.000Z
"
`;

exports[`branches > recover by id/table output 1`] = `
"branch
┌──────────────┬────────────────────────┬───────────────┬──────────────────────────┐
│ Name │ Id │ Current State │ Created At │
├──────────────┼────────────────────────┼───────────────┼──────────────────────────┤
│ sunny-branch │ br-sunny-branch-123456 │ ready │ 2021-01-01T00:00:00.000Z │
└──────────────┴────────────────────────┴───────────────┴──────────────────────────┘
"
`;

exports[`branches > recover by name 1`] = `
"id: br-gone-branch-123456
name: gone-branch
current_state: ready
created_at: 2021-01-01T00:00:00.000Z
"
`;

exports[`branches > remove expiration date 1`] = `
"id: br-sunny-branch-123456
name: test_branch
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/commands/branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ describe("branches", () => {
});
});

test("list with include-deleted shows deleted state and recovery", async ({
testCliCommand,
}) => {
await testCliCommand(
["branches", "list", "--project-id", "test", "--include-deleted"],
{ mockDir: "deleted_branches", outputTable: true },
);
});

test("list without include-deleted omits deleted branches", async ({
testCliCommand,
}) => {
await testCliCommand(["branches", "list", "--project-id", "test"], {
mockDir: "deleted_branches",
outputTable: true,
});
});

test("list/table marks the branch pinned in .neon as [current]", async ({
testCliCommand,
}) => {
Expand Down Expand Up @@ -275,6 +293,49 @@ describe("branches", () => {
]);
});

test("delete by id with hard-delete", async ({ testCliCommand }) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we only testing delete by id and recover by id and name? We can delete by name too, we should test that too.

await testCliCommand([
"branches",
"delete",
"br-harddel-branch-123456",
"--project-id",
"test",
"--hard-delete",
]);
});

/* recover */

test("recover by id", async ({ testCliCommand }) => {
await testCliCommand([
"branches",
"recover",
"br-sunny-branch-123456",
"--project-id",
"test",
]);
});

test("recover by id/table output", async ({ testCliCommand }) => {
await testCliCommand(
[
"branches",
"recover",
"br-sunny-branch-123456",
"--project-id",
"test",
],
{ outputTable: true },
);
});

test("recover by name", async ({ testCliCommand }) => {
await testCliCommand(
["branches", "recover", "gone-branch", "--project-id", "test"],
{ mockDir: "deleted_branches" },
);
});

/* rename */

test("rename", async ({ testCliCommand }) => {
Expand Down
72 changes: 67 additions & 5 deletions packages/cli/src/commands/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const BRANCH_FIELDS: readonly (keyof Branch)[] = [
"current_state",
"created_at",
"expires_at",
"recovery",
];

const BRANCH_FIELDS_RESET: readonly (keyof Branch)[] = [
Expand Down Expand Up @@ -60,7 +61,15 @@ export const builder = (argv: yargs.Argv) =>
.command(
"list",
"List branches",
(yargs) => yargs,
(yargs) =>
yargs.options({
"include-deleted": {
describe:
"Include deleted branches that are still recoverable",
type: "boolean",
default: false,
},
}),
(args) => list(args as any),
)
.command(
Expand Down Expand Up @@ -243,9 +252,23 @@ export const builder = (argv: yargs.Argv) =>
.command(
"delete <id|name>",
"Delete a branch",
(yargs) => yargs,
(yargs) =>
yargs.options({
"hard-delete": {
describe:
"Permanently delete the branch immediately, skipping the 7-day recovery window",
type: "boolean",
default: false,
},
}),
(args) => deleteBranch(args as any),
)
.command(
"recover <id|name>",
"Recover a deleted branch within its recovery window",
(yargs) => yargs,
(args) => recover(args as any),
)
.command(
"get <id|name>",
"Get a branch",
Expand Down Expand Up @@ -305,20 +328,27 @@ export const handler = (args: yargs.Argv) => {
return args;
};

const list = async (props: ProjectScopeProps) => {
const list = async (props: ProjectScopeProps & { includeDeleted: boolean }) => {
const {
data: { branches, annotations },
} = await props.apiClient.listProjectBranches({
projectId: props.projectId,
include_deleted: props.includeDeleted,
});
// The branch pinned in the local context (.neon), so we can flag it as `[current]` — the
// one commands target by default and that `neonctl env pull` would read. The context
// stores the branch by name (preferred) or id, so match against either.
const currentBranch = contextBranch(readContextFile(props.contextFile));
writer(props).end(branches, {
fields: BRANCH_FIELDS,
renderHeaders: {
recovery: "Recoverable Until",
},
renderColumns: {
expires_at: (br) => br.expires_at || "never",
current_state: (br) =>
br.recovery ? "deleted" : (br.current_state ?? ""),
recovery: (br) => br.recovery?.recoverable_until ?? "",
// Word labels (not symbols) so they read clearly and match the existing `[anon]`.
name: (br) => {
const annotation = annotations[br.id];
Expand Down Expand Up @@ -512,10 +542,16 @@ const setDefault = async (props: ProjectScopeProps & IdOrNameProps) => {
});
};

const deleteBranch = async (props: ProjectScopeProps & IdOrNameProps) => {
const deleteBranch = async (
props: ProjectScopeProps & IdOrNameProps & { hardDelete: boolean },
) => {
const branchId = await branchIdFromProps(props);
const { data } = await retryOnLock(() =>
props.apiClient.deleteProjectBranch(props.projectId, branchId),
props.apiClient.deleteProjectBranch(
props.projectId,
branchId,
props.hardDelete,
),
);
// A 204 (branch already gone) carries no body; only a 200 returns it.
if (data) {
Expand All @@ -525,6 +561,32 @@ const deleteBranch = async (props: ProjectScopeProps & IdOrNameProps) => {
}
};

const recover = async (props: ProjectScopeProps & IdOrNameProps) => {
const branchId = await branchIdResolve({
branch: props.id,
apiClient: props.apiClient,
projectId: props.projectId,
includeDeleted: true,
});
const { data } = await retryOnLock(() =>
props.apiClient.recoverProjectBranch(props.projectId, branchId),
);
const out = writer(props);
out.write(data.branch, {
fields: BRANCH_FIELDS,
title: "branch",
emptyMessage: "No branches have been found.",
});
if (data.endpoints?.length) {
out.write(data.endpoints, {
fields: ["id", "created_at"],
title: "endpoints",
emptyMessage: "No endpoints have been found.",
});
}
out.end();
};

const get = async (props: ProjectScopeProps & IdOrNameProps) => {
const branchId = await branchIdFromProps(props);
const { data } = await props.apiClient.getProjectBranch(
Expand Down
Loading
Loading