feat(app): reset worktree
This commit is contained in:
@@ -942,6 +942,30 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetWorkspace = async (directory: string) => {
|
||||||
|
const current = currentProject()
|
||||||
|
if (!current) return
|
||||||
|
if (directory === current.worktree) return
|
||||||
|
|
||||||
|
const result = await globalSDK.client.worktree
|
||||||
|
.reset({ directory: current.worktree, worktreeResetInput: { directory } })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: "Failed to reset workspace",
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
title: "Workspace reset",
|
||||||
|
description: "Workspace now matches the default branch.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function DialogDeleteWorkspace(props: { directory: string }) {
|
function DialogDeleteWorkspace(props: { directory: string }) {
|
||||||
const name = createMemo(() => getFilename(props.directory))
|
const name = createMemo(() => getFilename(props.directory))
|
||||||
const [data, setData] = createStore({
|
const [data, setData] = createStore({
|
||||||
@@ -1000,6 +1024,66 @@ export default function Layout(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DialogResetWorkspace(props: { directory: string }) {
|
||||||
|
const name = createMemo(() => getFilename(props.directory))
|
||||||
|
const [data, setData] = createStore({
|
||||||
|
status: "loading" as "loading" | "ready" | "error",
|
||||||
|
dirty: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const current = currentProject()
|
||||||
|
if (!current) {
|
||||||
|
setData({ status: "error", dirty: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSDK.client.file
|
||||||
|
.status({ directory: props.directory })
|
||||||
|
.then((x) => {
|
||||||
|
const files = x.data ?? []
|
||||||
|
const dirty = files.length > 0
|
||||||
|
setData({ status: "ready", dirty })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setData({ status: "error", dirty: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
await resetWorkspace(props.directory)
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = () => {
|
||||||
|
if (data.status === "loading") return "Checking for unmerged changes..."
|
||||||
|
if (data.status === "error") return "Unable to verify git status."
|
||||||
|
if (!data.dirty) return "No unmerged changes detected."
|
||||||
|
return "Unmerged changes detected in this workspace."
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="Reset workspace">
|
||||||
|
<div class="flex flex-col gap-4 px-2.5 pb-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-14-regular text-text-strong">Reset workspace "{name()}"?</span>
|
||||||
|
<span class="text-12-regular text-text-weak">
|
||||||
|
{description()} This will reset the workspace to match the default branch.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="large" disabled={data.status === "loading"} onClick={handleReset}>
|
||||||
|
Reset workspace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
|
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
|
||||||
@@ -1391,6 +1475,12 @@ export default function Layout(props: ParentProps) {
|
|||||||
<DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
|
<DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
|
||||||
<DropdownMenu.ItemLabel>New session</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>New session</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
disabled={local()}
|
||||||
|
onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>Reset workspace</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
disabled={local()}
|
disabled={local()}
|
||||||
onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
|
onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
|
||||||
|
|||||||
@@ -159,6 +159,31 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.post(
|
||||||
|
"/worktree/reset",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Reset worktree",
|
||||||
|
description: "Reset a worktree branch to the primary default branch.",
|
||||||
|
operationId: "worktree.reset",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Worktree reset",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator("json", Worktree.reset.schema),
|
||||||
|
async (c) => {
|
||||||
|
const body = c.req.valid("json")
|
||||||
|
await Worktree.reset(body)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
"/resource",
|
"/resource",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ export namespace Worktree {
|
|||||||
|
|
||||||
export type RemoveInput = z.infer<typeof RemoveInput>
|
export type RemoveInput = z.infer<typeof RemoveInput>
|
||||||
|
|
||||||
|
export const ResetInput = z
|
||||||
|
.object({
|
||||||
|
directory: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "WorktreeResetInput",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ResetInput = z.infer<typeof ResetInput>
|
||||||
|
|
||||||
export const NotGitError = NamedError.create(
|
export const NotGitError = NamedError.create(
|
||||||
"WorktreeNotGitError",
|
"WorktreeNotGitError",
|
||||||
z.object({
|
z.object({
|
||||||
@@ -78,6 +88,13 @@ export namespace Worktree {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const ResetFailedError = NamedError.create(
|
||||||
|
"WorktreeResetFailedError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const ADJECTIVES = [
|
const ADJECTIVES = [
|
||||||
"brave",
|
"brave",
|
||||||
"calm",
|
"calm",
|
||||||
@@ -280,4 +297,114 @@ export namespace Worktree {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const reset = fn(ResetInput, async (input) => {
|
||||||
|
if (Instance.project.vcs !== "git") {
|
||||||
|
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = path.resolve(input.directory)
|
||||||
|
if (directory === path.resolve(Instance.worktree)) {
|
||||||
|
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||||
|
if (list.exitCode !== 0) {
|
||||||
|
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = outputText(list.stdout)
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||||
|
if (!line) return acc
|
||||||
|
if (line.startsWith("worktree ")) {
|
||||||
|
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
const current = acc[acc.length - 1]
|
||||||
|
if (!current) return acc
|
||||||
|
if (line.startsWith("branch ")) {
|
||||||
|
current.branch = line.slice("branch ".length).trim()
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
|
||||||
|
if (!entry?.path) {
|
||||||
|
throw new ResetFailedError({ message: "Worktree not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
|
||||||
|
if (remoteList.exitCode !== 0) {
|
||||||
|
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const remotes = outputText(remoteList.stdout)
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const remote = remotes.includes("origin")
|
||||||
|
? "origin"
|
||||||
|
: remotes.length === 1
|
||||||
|
? remotes[0]
|
||||||
|
: remotes.includes("upstream")
|
||||||
|
? "upstream"
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const remoteHead = remote
|
||||||
|
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
|
||||||
|
: { exitCode: 1, stdout: undefined, stderr: undefined }
|
||||||
|
|
||||||
|
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
|
||||||
|
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
|
||||||
|
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
|
||||||
|
|
||||||
|
const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
|
||||||
|
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
|
||||||
|
.quiet()
|
||||||
|
.nothrow()
|
||||||
|
.cwd(Instance.worktree)
|
||||||
|
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
|
||||||
|
|
||||||
|
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
|
||||||
|
if (!target) {
|
||||||
|
throw new ResetFailedError({ message: "Default branch not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteBranch) {
|
||||||
|
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
|
||||||
|
if (fetch.exitCode !== 0) {
|
||||||
|
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkout = await $`git checkout ${target}`.quiet().nothrow().cwd(entry.path)
|
||||||
|
if (checkout.exitCode !== 0) {
|
||||||
|
throw new ResetFailedError({ message: errorText(checkout) || `Failed to checkout ${target}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreeBranch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||||
|
if (!worktreeBranch) {
|
||||||
|
throw new ResetFailedError({ message: "Worktree branch not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = await $`git reset --hard ${target}`.quiet().nothrow().cwd(entry.path)
|
||||||
|
if (reset.exitCode !== 0) {
|
||||||
|
throw new ResetFailedError({ message: errorText(reset) || "Failed to reset worktree" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchReset = await $`git branch -f ${worktreeBranch} ${target}`.quiet().nothrow().cwd(entry.path)
|
||||||
|
if (branchReset.exitCode !== 0) {
|
||||||
|
throw new ResetFailedError({ message: errorText(branchReset) || "Failed to update worktree branch" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkoutBranch = await $`git checkout ${worktreeBranch}`.quiet().nothrow().cwd(entry.path)
|
||||||
|
if (checkoutBranch.exitCode !== 0) {
|
||||||
|
throw new ResetFailedError({ message: errorText(checkoutBranch) || "Failed to checkout worktree branch" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ import type {
|
|||||||
WorktreeRemoveErrors,
|
WorktreeRemoveErrors,
|
||||||
WorktreeRemoveInput,
|
WorktreeRemoveInput,
|
||||||
WorktreeRemoveResponses,
|
WorktreeRemoveResponses,
|
||||||
|
WorktreeResetErrors,
|
||||||
|
WorktreeResetInput,
|
||||||
|
WorktreeResetResponses,
|
||||||
} from "./types.gen.js"
|
} from "./types.gen.js"
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<
|
||||||
@@ -745,6 +748,41 @@ export class Worktree extends HeyApiClient {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset worktree
|
||||||
|
*
|
||||||
|
* Reset a worktree branch to the primary default branch.
|
||||||
|
*/
|
||||||
|
public reset<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
directory?: string
|
||||||
|
worktreeResetInput?: WorktreeResetInput
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "directory" },
|
||||||
|
{ key: "worktreeResetInput", map: "body" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<WorktreeResetResponses, WorktreeResetErrors, ThrowOnError>({
|
||||||
|
url: "/experimental/worktree/reset",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Resource extends HeyApiClient {
|
export class Resource extends HeyApiClient {
|
||||||
|
|||||||
@@ -1912,6 +1912,10 @@ export type WorktreeRemoveInput = {
|
|||||||
directory: string
|
directory: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorktreeResetInput = {
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
|
|
||||||
export type McpResource = {
|
export type McpResource = {
|
||||||
name: string
|
name: string
|
||||||
uri: string
|
uri: string
|
||||||
@@ -2630,6 +2634,33 @@ export type WorktreeCreateResponses = {
|
|||||||
|
|
||||||
export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]
|
export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]
|
||||||
|
|
||||||
|
export type WorktreeResetData = {
|
||||||
|
body?: WorktreeResetInput
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
}
|
||||||
|
url: "/experimental/worktree/reset"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorktreeResetErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request
|
||||||
|
*/
|
||||||
|
400: BadRequestError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors]
|
||||||
|
|
||||||
|
export type WorktreeResetResponses = {
|
||||||
|
/**
|
||||||
|
* Worktree reset
|
||||||
|
*/
|
||||||
|
200: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses]
|
||||||
|
|
||||||
export type ExperimentalResourceListData = {
|
export type ExperimentalResourceListData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|||||||
Reference in New Issue
Block a user