revert: roll back git-backed review modes (#19295)
This commit is contained in:
@@ -190,7 +190,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
input.sdk.vcs.get().then((x) => {
|
input.sdk.vcs.get().then((x) => {
|
||||||
const next = x.data ?? input.store.vcs
|
const next = x.data ?? input.store.vcs
|
||||||
input.setStore("vcs", next)
|
input.setStore("vcs", next)
|
||||||
if (next) input.vcsCache.setStore("value", next)
|
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||||
|
|||||||
@@ -494,10 +494,8 @@ describe("applyDirectoryEvent", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("updates vcs branch in store and cache", () => {
|
test("updates vcs branch in store and cache", () => {
|
||||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
const [store, setStore] = createStore(baseState())
|
||||||
const [cacheStore, setCacheStore] = createStore({
|
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
||||||
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
|
||||||
})
|
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||||
@@ -513,8 +511,8 @@ describe("applyDirectoryEvent", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
expect(store.vcs).toEqual({ branch: "feature/test" })
|
||||||
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||||
|
|||||||
@@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vcs.branch.updated": {
|
case "vcs.branch.updated": {
|
||||||
const props = event.properties as { branch?: string }
|
const props = event.properties as { branch: string }
|
||||||
if (input.store.vcs?.branch === props.branch) break
|
if (input.store.vcs?.branch === props.branch) break
|
||||||
const next = { ...input.store.vcs, branch: props.branch }
|
const next = { branch: props.branch }
|
||||||
input.setStore("vcs", next)
|
input.setStore("vcs", next)
|
||||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -535,8 +535,6 @@ export const dict = {
|
|||||||
"session.review.noVcs.createGit.action": "Create Git repository",
|
"session.review.noVcs.createGit.action": "Create Git repository",
|
||||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||||
"session.review.noChanges": "No changes",
|
"session.review.noChanges": "No changes",
|
||||||
"session.review.noUncommittedChanges": "No uncommitted changes yet",
|
|
||||||
"session.review.noBranchChanges": "No branch changes yet",
|
|
||||||
|
|
||||||
"session.files.selectToOpen": "Select a file to open",
|
"session.files.selectToOpen": "Select a file to open",
|
||||||
"session.files.all": "All files",
|
"session.files.all": "All files",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
@@ -64,9 +64,6 @@ import { formatServerError } from "@/utils/server-errors"
|
|||||||
const emptyUserMessages: UserMessage[] = []
|
const emptyUserMessages: UserMessage[] = []
|
||||||
const emptyFollowups: (FollowupDraft & { id: string })[] = []
|
const emptyFollowups: (FollowupDraft & { id: string })[] = []
|
||||||
|
|
||||||
type ChangeMode = "git" | "branch" | "session" | "turn"
|
|
||||||
type VcsMode = "git" | "branch"
|
|
||||||
|
|
||||||
type SessionHistoryWindowInput = {
|
type SessionHistoryWindowInput = {
|
||||||
sessionID: () => string | undefined
|
sessionID: () => string | undefined
|
||||||
messagesReady: () => boolean
|
messagesReady: () => boolean
|
||||||
@@ -427,16 +424,15 @@ export default function Page() {
|
|||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
const hasReview = createMemo(() => reviewCount() > 0)
|
||||||
const canReview = createMemo(() => !!params.id)
|
|
||||||
const reviewTab = createMemo(() => isDesktop())
|
const reviewTab = createMemo(() => isDesktop())
|
||||||
const tabState = createSessionTabs({
|
const tabState = createSessionTabs({
|
||||||
tabs,
|
tabs,
|
||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab,
|
normalizeTab,
|
||||||
review: reviewTab,
|
review: reviewTab,
|
||||||
hasReview: canReview,
|
hasReview,
|
||||||
})
|
})
|
||||||
const contextOpen = tabState.contextOpen
|
const contextOpen = tabState.contextOpen
|
||||||
const openedTabs = tabState.openedTabs
|
const openedTabs = tabState.openedTabs
|
||||||
@@ -459,12 +455,6 @@ export default function Page() {
|
|||||||
if (!id) return false
|
if (!id) return false
|
||||||
return sync.session.history.loading(id)
|
return sync.session.history.loading(id)
|
||||||
})
|
})
|
||||||
const diffsReady = createMemo(() => {
|
|
||||||
const id = params.id
|
|
||||||
if (!id) return true
|
|
||||||
if (!hasSessionReview()) return true
|
|
||||||
return sync.data.session_diff[id] !== undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const userMessages = createMemo(
|
const userMessages = createMemo(
|
||||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||||
@@ -518,22 +508,11 @@ export default function Page() {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
mobileTab: "session" as "session" | "changes",
|
mobileTab: "session" as "session" | "changes",
|
||||||
changes: "git" as ChangeMode,
|
changes: "session" as "session" | "turn",
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
deferRender: false,
|
deferRender: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [vcs, setVcs] = createStore({
|
|
||||||
diff: {
|
|
||||||
git: [] as FileDiff[],
|
|
||||||
branch: [] as FileDiff[],
|
|
||||||
},
|
|
||||||
ready: {
|
|
||||||
git: false,
|
|
||||||
branch: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const [followup, setFollowup] = createStore({
|
const [followup, setFollowup] = createStore({
|
||||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||||
failed: {} as Record<string, string | undefined>,
|
failed: {} as Record<string, string | undefined>,
|
||||||
@@ -560,68 +539,6 @@ export default function Page() {
|
|||||||
let refreshTimer: number | undefined
|
let refreshTimer: number | undefined
|
||||||
let diffFrame: number | undefined
|
let diffFrame: number | undefined
|
||||||
let diffTimer: number | undefined
|
let diffTimer: number | undefined
|
||||||
const vcsTask = new Map<VcsMode, Promise<void>>()
|
|
||||||
const vcsRun = new Map<VcsMode, number>()
|
|
||||||
|
|
||||||
const bumpVcs = (mode: VcsMode) => {
|
|
||||||
const next = (vcsRun.get(mode) ?? 0) + 1
|
|
||||||
vcsRun.set(mode, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetVcs = (mode?: VcsMode) => {
|
|
||||||
const list = mode ? [mode] : (["git", "branch"] as const)
|
|
||||||
list.forEach((item) => {
|
|
||||||
bumpVcs(item)
|
|
||||||
vcsTask.delete(item)
|
|
||||||
setVcs("diff", item, [])
|
|
||||||
setVcs("ready", item, false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadVcs = (mode: VcsMode, force = false) => {
|
|
||||||
if (sync.project?.vcs !== "git") return Promise.resolve()
|
|
||||||
if (!force && vcs.ready[mode]) return Promise.resolve()
|
|
||||||
|
|
||||||
if (force) {
|
|
||||||
if (vcsTask.has(mode)) bumpVcs(mode)
|
|
||||||
vcsTask.delete(mode)
|
|
||||||
setVcs("ready", mode, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = vcsTask.get(mode)
|
|
||||||
if (current) return current
|
|
||||||
|
|
||||||
const run = bumpVcs(mode)
|
|
||||||
|
|
||||||
const task = sdk.client.vcs
|
|
||||||
.diff({ mode })
|
|
||||||
.then((result) => {
|
|
||||||
if (vcsRun.get(mode) !== run) return
|
|
||||||
setVcs("diff", mode, result.data ?? [])
|
|
||||||
setVcs("ready", mode, true)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (vcsRun.get(mode) !== run) return
|
|
||||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
|
||||||
setVcs("diff", mode, [])
|
|
||||||
setVcs("ready", mode, true)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
|
|
||||||
})
|
|
||||||
|
|
||||||
vcsTask.set(mode, task)
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshVcs = () => {
|
|
||||||
resetVcs()
|
|
||||||
const mode = untrack(vcsMode)
|
|
||||||
if (!mode) return
|
|
||||||
if (!untrack(wantsReview)) return
|
|
||||||
void loadVcs(mode, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
createComputed((prev) => {
|
createComputed((prev) => {
|
||||||
const open = desktopReviewOpen()
|
const open = desktopReviewOpen()
|
||||||
@@ -637,42 +554,7 @@ export default function Page() {
|
|||||||
}, desktopReviewOpen())
|
}, desktopReviewOpen())
|
||||||
|
|
||||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||||
const list: ChangeMode[] = []
|
|
||||||
if (sync.project?.vcs === "git") list.push("git")
|
|
||||||
if (
|
|
||||||
sync.project?.vcs === "git" &&
|
|
||||||
sync.data.vcs?.branch &&
|
|
||||||
sync.data.vcs?.default_branch &&
|
|
||||||
sync.data.vcs.branch !== sync.data.vcs.default_branch
|
|
||||||
) {
|
|
||||||
list.push("branch")
|
|
||||||
}
|
|
||||||
list.push("session", "turn")
|
|
||||||
return list
|
|
||||||
})
|
|
||||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
|
||||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
|
||||||
})
|
|
||||||
const reviewDiffs = createMemo(() => {
|
|
||||||
if (store.changes === "git") return vcs.diff.git
|
|
||||||
if (store.changes === "branch") return vcs.diff.branch
|
|
||||||
if (store.changes === "session") return diffs()
|
|
||||||
return turnDiffs()
|
|
||||||
})
|
|
||||||
const reviewCount = createMemo(() => {
|
|
||||||
if (store.changes === "git") return vcs.diff.git.length
|
|
||||||
if (store.changes === "branch") return vcs.diff.branch.length
|
|
||||||
if (store.changes === "session") return sessionCount()
|
|
||||||
return turnDiffs().length
|
|
||||||
})
|
|
||||||
const hasReview = createMemo(() => reviewCount() > 0)
|
|
||||||
const reviewReady = createMemo(() => {
|
|
||||||
if (store.changes === "git") return vcs.ready.git
|
|
||||||
if (store.changes === "branch") return vcs.ready.branch
|
|
||||||
if (store.changes === "session") return !hasSessionReview() || diffsReady()
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.newSessionWorktree === "create") return "create"
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
@@ -738,7 +620,13 @@ export default function Page() {
|
|||||||
scrollToMessage(msgs[targetIndex], "auto")
|
scrollToMessage(msgs[targetIndex], "auto")
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionEmptyKey = createMemo(() => {
|
const diffsReady = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return true
|
||||||
|
if (!hasReview()) return true
|
||||||
|
return sync.data.session_diff[id] !== undefined
|
||||||
|
})
|
||||||
|
const reviewEmptyKey = createMemo(() => {
|
||||||
const project = sync.project
|
const project = sync.project
|
||||||
if (project && !project.vcs) return "session.review.noVcs"
|
if (project && !project.vcs) return "session.review.noVcs"
|
||||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||||
@@ -860,46 +748,13 @@ export default function Page() {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
() => {
|
() => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
setStore("changes", "git")
|
setStore("changes", "session")
|
||||||
setUi("pendingMessage", undefined)
|
setUi("pendingMessage", undefined)
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => sdk.directory,
|
|
||||||
() => {
|
|
||||||
resetVcs()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
|
|
||||||
(next, prev) => {
|
|
||||||
if (prev === undefined || same(next, prev)) return
|
|
||||||
refreshVcs()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const stopVcs = sdk.event.listen((evt) => {
|
|
||||||
if (evt.details.type !== "file.watcher.updated") return
|
|
||||||
const props =
|
|
||||||
typeof evt.details.properties === "object" && evt.details.properties
|
|
||||||
? (evt.details.properties as Record<string, unknown>)
|
|
||||||
: undefined
|
|
||||||
const file = typeof props?.file === "string" ? props.file : undefined
|
|
||||||
if (!file || file.startsWith(".git/")) return
|
|
||||||
refreshVcs()
|
|
||||||
})
|
|
||||||
onCleanup(stopVcs)
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => params.dir,
|
() => params.dir,
|
||||||
@@ -1022,40 +877,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||||
const wantsReview = createMemo(() =>
|
|
||||||
isDesktop()
|
|
||||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
|
||||||
: store.mobileTab === "changes",
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const list = changesOptions()
|
|
||||||
if (list.includes(store.changes)) return
|
|
||||||
const next = list[0]
|
|
||||||
if (!next) return
|
|
||||||
setStore("changes", next)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const mode = vcsMode()
|
|
||||||
if (!mode) return
|
|
||||||
if (!wantsReview()) return
|
|
||||||
void loadVcs(mode)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => sync.data.session_status[params.id ?? ""]?.type,
|
|
||||||
(next, prev) => {
|
|
||||||
const mode = vcsMode()
|
|
||||||
if (!mode) return
|
|
||||||
if (!wantsReview()) return
|
|
||||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
|
||||||
void loadVcs(mode, true)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const fileTreeTab = () => layout.fileTree.tab()
|
const fileTreeTab = () => layout.fileTree.tab()
|
||||||
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||||
@@ -1102,23 +923,21 @@ export default function Page() {
|
|||||||
loadFile: file.load,
|
loadFile: file.load,
|
||||||
})
|
})
|
||||||
|
|
||||||
const changesTitle = () => {
|
const changesOptions = ["session", "turn"] as const
|
||||||
if (!canReview()) {
|
const changesOptionsList = [...changesOptions]
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = (option: ChangeMode) => {
|
const changesTitle = () => {
|
||||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
if (!hasReview()) {
|
||||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
return null
|
||||||
if (option === "session") return language.t("ui.sessionReview.title")
|
|
||||||
return language.t("ui.sessionReview.title.lastTurn")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
options={changesOptions()}
|
options={changesOptionsList}
|
||||||
current={store.changes}
|
current={store.changes}
|
||||||
label={label}
|
label={(option) =>
|
||||||
|
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||||
|
}
|
||||||
onSelect={(option) => option && setStore("changes", option)}
|
onSelect={(option) => option && setStore("changes", option)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -1127,34 +946,20 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const empty = (text: string) => (
|
const emptyTurn = () => (
|
||||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const reviewEmptyText = createMemo(() => {
|
|
||||||
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
|
||||||
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
|
||||||
if (store.changes === "turn") return language.t("session.review.noChanges")
|
|
||||||
return language.t(sessionEmptyKey())
|
|
||||||
})
|
|
||||||
|
|
||||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||||
if (store.changes === "git" || store.changes === "branch") {
|
if (store.changes === "turn") return emptyTurn()
|
||||||
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
|
||||||
return empty(reviewEmptyText())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.changes === "turn") {
|
if (hasReview() && !diffsReady()) {
|
||||||
return empty(reviewEmptyText())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasSessionReview() && !diffsReady()) {
|
|
||||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionEmptyKey() === "session.review.noVcs") {
|
if (reviewEmptyKey() === "session.review.noVcs") {
|
||||||
return (
|
return (
|
||||||
<div class={input.emptyClass}>
|
<div class={input.emptyClass}>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -1174,7 +979,7 @@ export default function Page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={input.emptyClass}>
|
<div class={input.emptyClass}>
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1083,7 @@ export default function Page() {
|
|||||||
const pending = tree.pendingDiff
|
const pending = tree.pendingDiff
|
||||||
if (!pending) return
|
if (!pending) return
|
||||||
if (!tree.reviewScroll) return
|
if (!tree.reviewScroll) return
|
||||||
if (!reviewReady()) return
|
if (!diffsReady()) return
|
||||||
|
|
||||||
const attempt = (count: number) => {
|
const attempt = (count: number) => {
|
||||||
if (tree.pendingDiff !== pending) return
|
if (tree.pendingDiff !== pending) return
|
||||||
@@ -1319,7 +1124,10 @@ export default function Page() {
|
|||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
if (!wantsReview()) return
|
const wants = isDesktop()
|
||||||
|
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||||
|
: store.mobileTab === "changes"
|
||||||
|
if (!wants) return
|
||||||
if (sync.data.session_diff[id] !== undefined) return
|
if (sync.data.session_diff[id] !== undefined) return
|
||||||
if (sync.status === "loading") return
|
if (sync.status === "loading") return
|
||||||
|
|
||||||
@@ -1328,7 +1136,13 @@ export default function Page() {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => [sessionKey(), wantsReview()] as const,
|
() =>
|
||||||
|
[
|
||||||
|
sessionKey(),
|
||||||
|
isDesktop()
|
||||||
|
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||||
|
: store.mobileTab === "changes",
|
||||||
|
] as const,
|
||||||
([key, wants]) => {
|
([key, wants]) => {
|
||||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||||
@@ -2014,12 +1828,6 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionSidePanel
|
<SessionSidePanel
|
||||||
canReview={canReview}
|
|
||||||
diffs={reviewDiffs}
|
|
||||||
diffsReady={reviewReady}
|
|
||||||
empty={reviewEmptyText}
|
|
||||||
hasReview={hasReview}
|
|
||||||
reviewCount={reviewCount}
|
|
||||||
reviewPanel={reviewPanel}
|
reviewPanel={reviewPanel}
|
||||||
activeDiff={tree.activeDiff}
|
activeDiff={tree.activeDiff}
|
||||||
focusReviewDiff={focusReviewDiff}
|
focusReviewDiff={focusReviewDiff}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
|||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
|
||||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
|
||||||
@@ -20,6 +19,7 @@ import { useCommand } from "@/context/command"
|
|||||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||||
@@ -27,12 +27,6 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
|||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
|
||||||
export function SessionSidePanel(props: {
|
export function SessionSidePanel(props: {
|
||||||
canReview: () => boolean
|
|
||||||
diffs: () => FileDiff[]
|
|
||||||
diffsReady: () => boolean
|
|
||||||
empty: () => string
|
|
||||||
hasReview: () => boolean
|
|
||||||
reviewCount: () => number
|
|
||||||
reviewPanel: () => JSX.Element
|
reviewPanel: () => JSX.Element
|
||||||
activeDiff?: string
|
activeDiff?: string
|
||||||
focusReviewDiff: (path: string) => void
|
focusReviewDiff: (path: string) => void
|
||||||
@@ -40,11 +34,12 @@ export function SessionSidePanel(props: {
|
|||||||
size: Sizing
|
size: Sizing
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
const sync = useSync()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { sessionKey, tabs, view } = useSessionLayout()
|
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
@@ -59,7 +54,24 @@ export function SessionSidePanel(props: {
|
|||||||
})
|
})
|
||||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||||
|
|
||||||
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
|
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||||
|
const hasReview = createMemo(() => reviewCount() > 0)
|
||||||
|
const diffsReady = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return true
|
||||||
|
if (!hasReview()) return true
|
||||||
|
return sync.data.session_diff[id] !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const reviewEmptyKey = createMemo(() => {
|
||||||
|
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
||||||
|
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||||
|
return "session.review.noChanges"
|
||||||
|
})
|
||||||
|
|
||||||
|
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||||
const kinds = createMemo(() => {
|
const kinds = createMemo(() => {
|
||||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||||
if (!a) return b
|
if (!a) return b
|
||||||
@@ -70,7 +82,7 @@ export function SessionSidePanel(props: {
|
|||||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||||
|
|
||||||
const out = new Map<string, "add" | "del" | "mix">()
|
const out = new Map<string, "add" | "del" | "mix">()
|
||||||
for (const diff of props.diffs()) {
|
for (const diff of diffs()) {
|
||||||
const file = normalize(diff.file)
|
const file = normalize(diff.file)
|
||||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||||
|
|
||||||
@@ -124,7 +136,7 @@ export function SessionSidePanel(props: {
|
|||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab,
|
normalizeTab,
|
||||||
review: reviewTab,
|
review: reviewTab,
|
||||||
hasReview: props.canReview,
|
hasReview,
|
||||||
})
|
})
|
||||||
const contextOpen = tabState.contextOpen
|
const contextOpen = tabState.contextOpen
|
||||||
const openedTabs = tabState.openedTabs
|
const openedTabs = tabState.openedTabs
|
||||||
@@ -229,12 +241,12 @@ export function SessionSidePanel(props: {
|
|||||||
onCleanup(stop)
|
onCleanup(stop)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={reviewTab() && props.canReview()}>
|
<Show when={reviewTab()}>
|
||||||
<Tabs.Trigger value="review">
|
<Tabs.Trigger value="review">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<div>{language.t("session.tab.review")}</div>
|
<div>{language.t("session.tab.review")}</div>
|
||||||
<Show when={props.hasReview()}>
|
<Show when={hasReview()}>
|
||||||
<div>{props.reviewCount()}</div>
|
<div>{reviewCount()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
@@ -291,7 +303,7 @@ export function SessionSidePanel(props: {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={reviewTab() && props.canReview()}>
|
<Show when={reviewTab()}>
|
||||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
@@ -365,10 +377,8 @@ export function SessionSidePanel(props: {
|
|||||||
>
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{props.reviewCount()}{" "}
|
{reviewCount()}{" "}
|
||||||
{language.t(
|
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
|
||||||
)}
|
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{language.t("session.files.all")}
|
{language.t("session.files.all")}
|
||||||
@@ -376,9 +386,9 @@ export function SessionSidePanel(props: {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
<Match when={hasReview()}>
|
||||||
<Show
|
<Show
|
||||||
when={props.diffsReady()}
|
when={diffsReady()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
{language.t("common.loading")}
|
{language.t("common.loading")}
|
||||||
@@ -397,7 +407,11 @@ export function SessionSidePanel(props: {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>{empty(props.empty())}</Match>
|
<Match when={true}>
|
||||||
|
{empty(
|
||||||
|
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
return sync.session.get(id)
|
return sync.session.get(id)
|
||||||
}
|
}
|
||||||
const hasReview = () => !!params.id
|
const hasReview = () => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return false
|
||||||
|
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
|
||||||
|
}
|
||||||
const normalizeTab = (tab: string) => {
|
const normalizeTab = (tab: string) => {
|
||||||
if (!tab.startsWith("file://")) return tab
|
if (!tab.startsWith("file://")) return tab
|
||||||
return file.tab(tab)
|
return file.tab(tab)
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider"
|
|||||||
import { Bus } from "../../bus"
|
import { Bus } from "../../bus"
|
||||||
import { MessageV2 } from "../../session/message-v2"
|
import { MessageV2 } from "../../session/message-v2"
|
||||||
import { SessionPrompt } from "@/session/prompt"
|
import { SessionPrompt } from "@/session/prompt"
|
||||||
import { Git } from "@/git"
|
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { Process } from "@/util/process"
|
import { Process } from "@/util/process"
|
||||||
|
import { git } from "@/util/git"
|
||||||
|
|
||||||
type GitHubAuthor = {
|
type GitHubAuthor = {
|
||||||
login: string
|
login: string
|
||||||
@@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get repo info
|
// Get repo info
|
||||||
const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||||
const parsed = parseGitHubRemote(info)
|
const parsed = parseGitHubRemote(info)
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||||
@@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({
|
|||||||
: "issue"
|
: "issue"
|
||||||
: undefined
|
: undefined
|
||||||
const gitText = async (args: string[]) => {
|
const gitText = async (args: string[]) => {
|
||||||
const result = await Git.run(args, { cwd: Instance.worktree })
|
const result = await git(args, { cwd: Instance.worktree })
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||||
}
|
}
|
||||||
return result.text().trim()
|
return result.text().trim()
|
||||||
}
|
}
|
||||||
const gitRun = async (args: string[]) => {
|
const gitRun = async (args: string[]) => {
|
||||||
const result = await Git.run(args, { cwd: Instance.worktree })
|
const result = await git(args, { cwd: Instance.worktree })
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
|
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
|
||||||
const commitChanges = async (summary: string, actor?: string) => {
|
const commitChanges = async (summary: string, actor?: string) => {
|
||||||
const args = ["commit", "-m", summary]
|
const args = ["commit", "-m", summary]
|
||||||
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { Git } from "@/git"
|
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Process } from "@/util/process"
|
import { Process } from "@/util/process"
|
||||||
|
import { git } from "@/util/git"
|
||||||
|
|
||||||
export const PrCommand = cmd({
|
export const PrCommand = cmd({
|
||||||
command: "pr <number>",
|
command: "pr <number>",
|
||||||
@@ -67,9 +67,9 @@ export const PrCommand = cmd({
|
|||||||
const remoteName = forkOwner
|
const remoteName = forkOwner
|
||||||
|
|
||||||
// Check if remote already exists
|
// Check if remote already exists
|
||||||
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
|
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||||
if (!remotes.split("\n").includes(remoteName)) {
|
if (!remotes.split("\n").includes(remoteName)) {
|
||||||
await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||||
cwd: Instance.worktree,
|
cwd: Instance.worktree,
|
||||||
})
|
})
|
||||||
UI.println(`Added fork remote: ${remoteName}`)
|
UI.println(`Added fork remote: ${remoteName}`)
|
||||||
@@ -77,7 +77,7 @@ export const PrCommand = cmd({
|
|||||||
|
|
||||||
// Set upstream to the fork so pushes go there
|
// Set upstream to the fork so pushes go there
|
||||||
const headRefName = prInfo.headRefName
|
const headRefName = prInfo.headRefName
|
||||||
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||||
cwd: Instance.worktree,
|
cwd: Instance.worktree,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Git } from "@/git"
|
import { git } from "@/util/git"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
@@ -420,7 +420,7 @@ export namespace File {
|
|||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const diffOutput = (
|
const diffOutput = (
|
||||||
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
})
|
})
|
||||||
).text()
|
).text()
|
||||||
@@ -440,7 +440,7 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const untrackedOutput = (
|
const untrackedOutput = (
|
||||||
await Git.run(
|
await git(
|
||||||
[
|
[
|
||||||
"-c",
|
"-c",
|
||||||
"core.fsmonitor=false",
|
"core.fsmonitor=false",
|
||||||
@@ -473,7 +473,7 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deletedOutput = (
|
const deletedOutput = (
|
||||||
await Git.run(
|
await git(
|
||||||
[
|
[
|
||||||
"-c",
|
"-c",
|
||||||
"core.fsmonitor=false",
|
"core.fsmonitor=false",
|
||||||
@@ -564,17 +564,17 @@ export namespace File {
|
|||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
if (Instance.project.vcs === "git") {
|
||||||
let diff = (
|
let diff = (
|
||||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||||
).text()
|
).text()
|
||||||
if (!diff.trim()) {
|
if (!diff.trim()) {
|
||||||
diff = (
|
diff = (
|
||||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
})
|
})
|
||||||
).text()
|
).text()
|
||||||
}
|
}
|
||||||
if (diff.trim()) {
|
if (diff.trim()) {
|
||||||
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||||
context: Infinity,
|
context: Infinity,
|
||||||
ignoreWhitespace: true,
|
ignoreWhitespace: true,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { BusEvent } from "@/bus/bus-event"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Git } from "@/git"
|
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
|
import { git } from "@/util/git"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { FileIgnore } from "./ignore"
|
import { FileIgnore } from "./ignore"
|
||||||
@@ -130,7 +130,7 @@ export namespace FileWatcher {
|
|||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
if (Instance.project.vcs === "git") {
|
||||||
const result = yield* Effect.promise(() =>
|
const result = yield* Effect.promise(() =>
|
||||||
Git.run(["rev-parse", "--git-dir"], {
|
git(["rev-parse", "--git-dir"], {
|
||||||
cwd: Instance.project.worktree,
|
cwd: Instance.project.worktree,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|
||||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
|
|
||||||
export namespace Git {
|
|
||||||
const cfg = [
|
|
||||||
"--no-optional-locks",
|
|
||||||
"-c",
|
|
||||||
"core.autocrlf=false",
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.longpaths=true",
|
|
||||||
"-c",
|
|
||||||
"core.symlinks=true",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const out = (result: { text(): string }) => result.text().trim()
|
|
||||||
const nuls = (text: string) => text.split("\0").filter(Boolean)
|
|
||||||
const fail = (err: unknown) =>
|
|
||||||
({
|
|
||||||
exitCode: 1,
|
|
||||||
text: () => "",
|
|
||||||
stdout: Buffer.alloc(0),
|
|
||||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
|
||||||
}) satisfies Result
|
|
||||||
|
|
||||||
export type Kind = "added" | "deleted" | "modified"
|
|
||||||
|
|
||||||
export type Base = {
|
|
||||||
readonly name: string
|
|
||||||
readonly ref: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Item = {
|
|
||||||
readonly file: string
|
|
||||||
readonly code: string
|
|
||||||
readonly status: Kind
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Stat = {
|
|
||||||
readonly file: string
|
|
||||||
readonly additions: number
|
|
||||||
readonly deletions: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Result {
|
|
||||||
readonly exitCode: number
|
|
||||||
readonly text: () => string
|
|
||||||
readonly stdout: Buffer
|
|
||||||
readonly stderr: Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
readonly cwd: string
|
|
||||||
readonly env?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
|
|
||||||
readonly branch: (cwd: string) => Effect.Effect<string | undefined>
|
|
||||||
readonly prefix: (cwd: string) => Effect.Effect<string>
|
|
||||||
readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
|
|
||||||
readonly hasHead: (cwd: string) => Effect.Effect<boolean>
|
|
||||||
readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
|
|
||||||
readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
|
|
||||||
readonly status: (cwd: string) => Effect.Effect<Item[]>
|
|
||||||
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
|
|
||||||
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const kind = (code: string): Kind => {
|
|
||||||
if (code === "??") return "added"
|
|
||||||
if (code.includes("U")) return "modified"
|
|
||||||
if (code.includes("A") && !code.includes("D")) return "added"
|
|
||||||
if (code.includes("D") && !code.includes("A")) return "deleted"
|
|
||||||
return "modified"
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
|
|
||||||
const run = Effect.fn("Git.run")(
|
|
||||||
function* (args: string[], opts: Options) {
|
|
||||||
const proc = ChildProcess.make("git", [...cfg, ...args], {
|
|
||||||
cwd: opts.cwd,
|
|
||||||
env: opts.env,
|
|
||||||
extendEnv: true,
|
|
||||||
stdin: "ignore",
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [stdout, stderr] = yield* Effect.all(
|
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
exitCode: yield* handle.exitCode,
|
|
||||||
text: () => stdout,
|
|
||||||
stdout: Buffer.from(stdout),
|
|
||||||
stderr: Buffer.from(stderr),
|
|
||||||
} satisfies Result
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch((err) => Effect.succeed(fail(err))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
|
|
||||||
return (yield* run(args, opts)).text()
|
|
||||||
})
|
|
||||||
|
|
||||||
const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
|
|
||||||
return (yield* text(args, opts))
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
})
|
|
||||||
|
|
||||||
const refs = Effect.fnUntraced(function* (cwd: string) {
|
|
||||||
return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
|
|
||||||
})
|
|
||||||
|
|
||||||
const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
|
|
||||||
const result = yield* run(["config", "init.defaultBranch"], { cwd })
|
|
||||||
const name = out(result)
|
|
||||||
if (!name || !list.includes(name)) return
|
|
||||||
return { name, ref: name } satisfies Base
|
|
||||||
})
|
|
||||||
|
|
||||||
const primary = Effect.fnUntraced(function* (cwd: string) {
|
|
||||||
const list = yield* lines(["remote"], { cwd })
|
|
||||||
if (list.includes("origin")) return "origin"
|
|
||||||
if (list.length === 1) return list[0]
|
|
||||||
if (list.includes("upstream")) return "upstream"
|
|
||||||
return list[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
const branch = Effect.fn("Git.branch")(function* (cwd: string) {
|
|
||||||
const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
|
|
||||||
if (result.exitCode !== 0) return
|
|
||||||
const text = out(result)
|
|
||||||
return text || undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
|
|
||||||
const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
|
|
||||||
if (result.exitCode !== 0) return ""
|
|
||||||
return out(result)
|
|
||||||
})
|
|
||||||
|
|
||||||
const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
|
|
||||||
const remote = yield* primary(cwd)
|
|
||||||
if (remote) {
|
|
||||||
const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
|
|
||||||
if (head.exitCode === 0) {
|
|
||||||
const ref = out(head).replace(/^refs\/remotes\//, "")
|
|
||||||
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
|
|
||||||
if (name) return { name, ref } satisfies Base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = yield* refs(cwd)
|
|
||||||
const next = yield* configured(cwd, list)
|
|
||||||
if (next) return next
|
|
||||||
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
|
|
||||||
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
|
|
||||||
const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
|
|
||||||
return result.exitCode === 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
|
|
||||||
const result = yield* run(["merge-base", base, head], { cwd })
|
|
||||||
if (result.exitCode !== 0) return
|
|
||||||
const text = out(result)
|
|
||||||
return text || undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
|
|
||||||
const target = prefix ? `${prefix}${file}` : file
|
|
||||||
const result = yield* run(["show", `${ref}:${target}`], { cwd })
|
|
||||||
if (result.exitCode !== 0) return ""
|
|
||||||
if (result.stdout.includes(0)) return ""
|
|
||||||
return result.text()
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("Git.status")(function* (cwd: string) {
|
|
||||||
return nuls(
|
|
||||||
yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
|
|
||||||
cwd,
|
|
||||||
}),
|
|
||||||
).flatMap((item) => {
|
|
||||||
const file = item.slice(3)
|
|
||||||
if (!file) return []
|
|
||||||
const code = item.slice(0, 2)
|
|
||||||
return [{ file, code, status: kind(code) } satisfies Item]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
|
|
||||||
const list = nuls(
|
|
||||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
|
|
||||||
)
|
|
||||||
return list.flatMap((code, idx) => {
|
|
||||||
if (idx % 2 !== 0) return []
|
|
||||||
const file = list[idx + 1]
|
|
||||||
if (!code || !file) return []
|
|
||||||
return [{ file, code, status: kind(code) } satisfies Item]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
|
|
||||||
return nuls(
|
|
||||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
|
|
||||||
).flatMap((item) => {
|
|
||||||
const a = item.indexOf("\t")
|
|
||||||
const b = item.indexOf("\t", a + 1)
|
|
||||||
if (a === -1 || b === -1) return []
|
|
||||||
const file = item.slice(b + 1)
|
|
||||||
if (!file) return []
|
|
||||||
const adds = item.slice(0, a)
|
|
||||||
const dels = item.slice(a + 1, b)
|
|
||||||
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
|
|
||||||
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
file,
|
|
||||||
additions: Number.isFinite(additions) ? additions : 0,
|
|
||||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
|
||||||
} satisfies Stat,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
run,
|
|
||||||
branch,
|
|
||||||
prefix,
|
|
||||||
defaultBranch,
|
|
||||||
hasHead,
|
|
||||||
mergeBase,
|
|
||||||
show,
|
|
||||||
status,
|
|
||||||
diff,
|
|
||||||
stats,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(CrossSpawnSpawner.layer),
|
|
||||||
Layer.provide(NodeFileSystem.layer),
|
|
||||||
Layer.provide(NodePath.layer),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function run(args: string[], opts: Options) {
|
|
||||||
return runPromise((git) => git.run(args, opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function branch(cwd: string) {
|
|
||||||
return runPromise((git) => git.branch(cwd))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prefix(cwd: string) {
|
|
||||||
return runPromise((git) => git.prefix(cwd))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultBranch(cwd: string) {
|
|
||||||
return runPromise((git) => git.defaultBranch(cwd))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasHead(cwd: string) {
|
|
||||||
return runPromise((git) => git.hasHead(cwd))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeBase(cwd: string, base: string, head?: string) {
|
|
||||||
return runPromise((git) => git.mergeBase(cwd, base, head))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function show(cwd: string, ref: string, file: string, prefix?: string) {
|
|
||||||
return runPromise((git) => git.show(cwd, ref, file, prefix))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function status(cwd: string) {
|
|
||||||
return runPromise((git) => git.status(cwd))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function diff(cwd: string, ref: string) {
|
|
||||||
return runPromise((git) => git.diff(cwd, ref))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stats(cwd: string, ref: string) {
|
|
||||||
return runPromise((git) => git.stats(cwd, ref))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +1,17 @@
|
|||||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||||
import path from "path"
|
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { AppFileSystem } from "@/filesystem"
|
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Git } from "@/git"
|
|
||||||
import { Snapshot } from "@/snapshot"
|
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
|
import { git } from "@/util/git"
|
||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
export namespace Vcs {
|
export namespace Vcs {
|
||||||
const log = Log.create({ service: "vcs" })
|
const log = Log.create({ service: "vcs" })
|
||||||
|
|
||||||
const count = (text: string) => {
|
|
||||||
if (!text) return 0
|
|
||||||
if (!text.endsWith("\n")) return text.split("\n").length
|
|
||||||
return text.slice(0, -1).split("\n").length
|
|
||||||
}
|
|
||||||
|
|
||||||
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
|
||||||
const full = path.join(cwd, file)
|
|
||||||
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
|
||||||
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
|
||||||
if (Buffer.from(buf).includes(0)) return ""
|
|
||||||
return Buffer.from(buf).toString("utf8")
|
|
||||||
})
|
|
||||||
|
|
||||||
const nums = (list: Git.Stat[]) =>
|
|
||||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
|
||||||
|
|
||||||
const merge = (...lists: Git.Item[][]) => {
|
|
||||||
const out = new Map<string, Git.Item>()
|
|
||||||
lists.flat().forEach((item) => {
|
|
||||||
if (!out.has(item.file)) out.set(item.file, item)
|
|
||||||
})
|
|
||||||
return [...out.values()]
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = Effect.fnUntraced(function* (
|
|
||||||
fs: AppFileSystem.Interface,
|
|
||||||
git: Git.Interface,
|
|
||||||
cwd: string,
|
|
||||||
ref: string | undefined,
|
|
||||||
list: Git.Item[],
|
|
||||||
map: Map<string, { additions: number; deletions: number }>,
|
|
||||||
) {
|
|
||||||
const base = ref ? yield* git.prefix(cwd) : ""
|
|
||||||
const next = yield* Effect.forEach(
|
|
||||||
list,
|
|
||||||
(item) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
|
||||||
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
|
||||||
const stat = map.get(item.file)
|
|
||||||
return {
|
|
||||||
file: item.file,
|
|
||||||
before,
|
|
||||||
after,
|
|
||||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
|
||||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
|
||||||
status: item.status,
|
|
||||||
} satisfies Snapshot.FileDiff
|
|
||||||
}),
|
|
||||||
{ concurrency: 8 },
|
|
||||||
)
|
|
||||||
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
|
||||||
})
|
|
||||||
|
|
||||||
const track = Effect.fnUntraced(function* (
|
|
||||||
fs: AppFileSystem.Interface,
|
|
||||||
git: Git.Interface,
|
|
||||||
cwd: string,
|
|
||||||
ref: string | undefined,
|
|
||||||
) {
|
|
||||||
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
|
||||||
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
|
||||||
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
|
||||||
})
|
|
||||||
|
|
||||||
const compare = Effect.fnUntraced(function* (
|
|
||||||
fs: AppFileSystem.Interface,
|
|
||||||
git: Git.Interface,
|
|
||||||
cwd: string,
|
|
||||||
ref: string,
|
|
||||||
) {
|
|
||||||
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
|
||||||
concurrency: 3,
|
|
||||||
})
|
|
||||||
return yield* files(
|
|
||||||
fs,
|
|
||||||
git,
|
|
||||||
cwd,
|
|
||||||
ref,
|
|
||||||
merge(
|
|
||||||
list,
|
|
||||||
extra.filter((item) => item.code === "??"),
|
|
||||||
),
|
|
||||||
nums(stats),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Mode = z.enum(["git", "branch"])
|
|
||||||
export type Mode = z.infer<typeof Mode>
|
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
BranchUpdated: BusEvent.define(
|
BranchUpdated: BusEvent.define(
|
||||||
"vcs.branch.updated",
|
"vcs.branch.updated",
|
||||||
@@ -118,7 +24,6 @@ export namespace Vcs {
|
|||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
branch: z.string().optional(),
|
branch: z.string().optional(),
|
||||||
default_branch: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
.meta({
|
.meta({
|
||||||
ref: "VcsInfo",
|
ref: "VcsInfo",
|
||||||
@@ -128,40 +33,42 @@ export namespace Vcs {
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
readonly init: () => Effect.Effect<void>
|
||||||
readonly branch: () => Effect.Effect<string | undefined>
|
readonly branch: () => Effect.Effect<string | undefined>
|
||||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
|
||||||
readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
current: string | undefined
|
current: string | undefined
|
||||||
root: Git.Base | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, Bus.Service> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
const git = yield* Git.Service
|
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Vcs.state")((ctx) =>
|
Effect.fn("Vcs.state")((ctx) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (ctx.project.vcs !== "git") {
|
if (ctx.project.vcs !== "git") {
|
||||||
return { current: undefined, root: undefined }
|
return { current: undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
const get = () => Effect.runPromise(git.branch(ctx.directory))
|
const get = async () => {
|
||||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||||
concurrency: 2,
|
cwd: ctx.worktree,
|
||||||
})
|
})
|
||||||
const value = { current, root }
|
if (result.exitCode !== 0) return undefined
|
||||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
const text = result.text().trim()
|
||||||
|
return text || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
current: yield* Effect.promise(() => get()),
|
||||||
|
}
|
||||||
|
log.info("initialized", { branch: value.current })
|
||||||
|
|
||||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||||
Stream.runForEach((_evt) =>
|
Stream.runForEach(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = yield* Effect.promise(() => get())
|
const next = yield* Effect.promise(() => get())
|
||||||
if (next !== value.current) {
|
if (next !== value.current) {
|
||||||
@@ -186,36 +93,11 @@ export namespace Vcs {
|
|||||||
branch: Effect.fn("Vcs.branch")(function* () {
|
branch: Effect.fn("Vcs.branch")(function* () {
|
||||||
return yield* InstanceState.use(state, (x) => x.current)
|
return yield* InstanceState.use(state, (x) => x.current)
|
||||||
}),
|
}),
|
||||||
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
|
||||||
return yield* InstanceState.use(state, (x) => x.root?.name)
|
|
||||||
}),
|
|
||||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
|
||||||
const value = yield* InstanceState.get(state)
|
|
||||||
if (Instance.project.vcs !== "git") return []
|
|
||||||
if (mode === "git") {
|
|
||||||
return yield* track(
|
|
||||||
fs,
|
|
||||||
git,
|
|
||||||
Instance.directory,
|
|
||||||
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value.root) return []
|
|
||||||
if (value.current && value.current === value.root.name) return []
|
|
||||||
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
|
|
||||||
if (!ref) return []
|
|
||||||
return yield* compare(fs, git, Instance.directory, ref)
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
Layer.provide(Git.defaultLayer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Bus.layer),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
@@ -226,12 +108,4 @@ export namespace Vcs {
|
|||||||
export function branch() {
|
export function branch() {
|
||||||
return runPromise((svc) => svc.branch())
|
return runPromise((svc) => svc.branch())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultBranch() {
|
|
||||||
return runPromise((svc) => svc.defaultBranch())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function diff(mode: Mode) {
|
|
||||||
return runPromise((svc) => svc.diff(mode))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import { websocket } from "hono/bun"
|
|||||||
import { HTTPException } from "hono/http-exception"
|
import { HTTPException } from "hono/http-exception"
|
||||||
import { errors } from "./error"
|
import { errors } from "./error"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Snapshot } from "@/snapshot"
|
|
||||||
import { QuestionRoutes } from "./routes/question"
|
import { QuestionRoutes } from "./routes/question"
|
||||||
import { PermissionRoutes } from "./routes/permission"
|
import { PermissionRoutes } from "./routes/permission"
|
||||||
import { GlobalRoutes } from "./routes/global"
|
import { GlobalRoutes } from "./routes/global"
|
||||||
@@ -338,40 +337,12 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
const branch = await Vcs.branch()
|
||||||
return c.json({
|
return c.json({
|
||||||
branch,
|
branch,
|
||||||
default_branch,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
|
||||||
"/vcs/diff",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Get VCS diff",
|
|
||||||
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
|
||||||
operationId: "vcs.diff",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "VCS diff",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(Snapshot.FileDiff.array()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"query",
|
|
||||||
z.object({
|
|
||||||
mode: Vcs.Mode,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
return c.json(await Vcs.diff(c.req.valid("query").mode))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
.get(
|
||||||
"/command",
|
"/command",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { lazy } from "../util/lazy"
|
|||||||
import { Lock } from "../util/lock"
|
import { Lock } from "../util/lock"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Git } from "@/git"
|
|
||||||
import { Glob } from "../util/glob"
|
import { Glob } from "../util/glob"
|
||||||
|
import { git } from "@/util/git"
|
||||||
|
|
||||||
export namespace Storage {
|
export namespace Storage {
|
||||||
const log = Log.create({ service: "storage" })
|
const log = Log.create({ service: "storage" })
|
||||||
@@ -49,7 +49,7 @@ export namespace Storage {
|
|||||||
}
|
}
|
||||||
if (!worktree) continue
|
if (!worktree) continue
|
||||||
if (!(await Filesystem.isDir(worktree))) continue
|
if (!(await Filesystem.isDir(worktree))) continue
|
||||||
const result = await Git.run(["rev-list", "--max-parents=0", "--all"], {
|
const result = await git(["rev-list", "--max-parents=0", "--all"], {
|
||||||
cwd: worktree,
|
cwd: worktree,
|
||||||
})
|
})
|
||||||
const [id] = result
|
const [id] = result
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Process } from "./process"
|
||||||
|
|
||||||
|
export interface GitResult {
|
||||||
|
exitCode: number
|
||||||
|
text(): string
|
||||||
|
stdout: Buffer
|
||||||
|
stderr: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a git command.
|
||||||
|
*
|
||||||
|
* Uses Process helpers with stdin ignored to avoid protocol pipe inheritance
|
||||||
|
* issues in embedded/client environments.
|
||||||
|
*/
|
||||||
|
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
|
||||||
|
return Process.run(["git", ...args], {
|
||||||
|
cwd: opts.cwd,
|
||||||
|
env: opts.env,
|
||||||
|
stdin: "ignore",
|
||||||
|
nothrow: true,
|
||||||
|
})
|
||||||
|
.then((result) => ({
|
||||||
|
exitCode: result.code,
|
||||||
|
text: () => result.stdout.toString(),
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
}))
|
||||||
|
.catch((error) => ({
|
||||||
|
exitCode: 1,
|
||||||
|
text: () => "",
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.from(error instanceof Error ? error.message : String(error)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import { Log } from "../util/log"
|
|||||||
import { Slug } from "@opencode-ai/util/slug"
|
import { Slug } from "@opencode-ai/util/slug"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Git } from "@/git"
|
|
||||||
import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
@@ -505,24 +504,56 @@ export namespace Worktree {
|
|||||||
|
|
||||||
const worktreePath = entry.path
|
const worktreePath = entry.path
|
||||||
|
|
||||||
const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree))
|
const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
|
||||||
if (!base) {
|
if (remoteList.code !== 0) {
|
||||||
|
throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const remotes = remoteList.text
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const remote = remotes.includes("origin")
|
||||||
|
? "origin"
|
||||||
|
: remotes.length === 1
|
||||||
|
? remotes[0]
|
||||||
|
: remotes.includes("upstream")
|
||||||
|
? "upstream"
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const remoteHead = remote
|
||||||
|
? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
|
||||||
|
: { code: 1, text: "", stderr: "" }
|
||||||
|
|
||||||
|
const remoteRef = remoteHead.code === 0 ? remoteHead.text.trim() : ""
|
||||||
|
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
|
||||||
|
const remoteBranch =
|
||||||
|
remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
|
||||||
|
|
||||||
|
const [mainCheck, masterCheck] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree }),
|
||||||
|
git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree }),
|
||||||
|
],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const localBranch = mainCheck.code === 0 ? "main" : masterCheck.code === 0 ? "master" : ""
|
||||||
|
|
||||||
|
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
|
||||||
|
if (!target) {
|
||||||
throw new ResetFailedError({ message: "Default branch not found" })
|
throw new ResetFailedError({ message: "Default branch not found" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const sep = base.ref.indexOf("/")
|
if (remoteBranch) {
|
||||||
if (base.ref !== base.name && sep > 0) {
|
|
||||||
const remote = base.ref.slice(0, sep)
|
|
||||||
const branch = base.ref.slice(sep + 1)
|
|
||||||
yield* gitExpect(
|
yield* gitExpect(
|
||||||
["fetch", remote, branch],
|
["fetch", remote, remoteBranch],
|
||||||
{ cwd: Instance.worktree },
|
{ cwd: Instance.worktree },
|
||||||
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
|
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* gitExpect(
|
yield* gitExpect(
|
||||||
["reset", "--hard", base.ref],
|
["reset", "--hard", target],
|
||||||
{ cwd: worktreePath },
|
{ cwd: worktreePath },
|
||||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
|
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { $ } from "bun"
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import fs from "fs/promises"
|
|
||||||
import path from "path"
|
|
||||||
import { ManagedRuntime } from "effect"
|
|
||||||
import { Git } from "../../src/git"
|
|
||||||
import { tmpdir } from "../fixture/fixture"
|
|
||||||
|
|
||||||
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
|
|
||||||
|
|
||||||
async function withGit<T>(body: (rt: ManagedRuntime.ManagedRuntime<Git.Service, never>) => Promise<T>) {
|
|
||||||
const rt = ManagedRuntime.make(Git.defaultLayer)
|
|
||||||
try {
|
|
||||||
return await body(rt)
|
|
||||||
} finally {
|
|
||||||
await rt.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Git", () => {
|
|
||||||
test("branch() returns current branch name", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
|
|
||||||
await withGit(async (rt) => {
|
|
||||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
|
||||||
expect(branch).toBeDefined()
|
|
||||||
expect(typeof branch).toBe("string")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("branch() returns undefined for non-git directories", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
|
|
||||||
await withGit(async (rt) => {
|
|
||||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
|
||||||
expect(branch).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("branch() returns undefined for detached HEAD", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim()
|
|
||||||
await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet()
|
|
||||||
|
|
||||||
await withGit(async (rt) => {
|
|
||||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
|
||||||
expect(branch).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("defaultBranch() uses init.defaultBranch when available", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await $`git branch -M trunk`.cwd(tmp.path).quiet()
|
|
||||||
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
|
|
||||||
|
|
||||||
await withGit(async (rt) => {
|
|
||||||
const branch = await rt.runPromise(Git.Service.use((git) => git.defaultBranch(tmp.path)))
|
|
||||||
expect(branch?.name).toBe("trunk")
|
|
||||||
expect(branch?.ref).toBe("trunk")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("status() handles special filenames", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
|
|
||||||
|
|
||||||
await withGit(async (rt) => {
|
|
||||||
const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path)))
|
|
||||||
expect(status).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
file: weird,
|
|
||||||
status: "added",
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("diff(), stats(), and mergeBase() parse tracked changes", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
|
||||||
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
|
|
||||||
await $`git add .`.cwd(tmp.path).quiet()
|
|
||||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
|
||||||
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
|
|
||||||
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
|
|
||||||
|
|
||||||
await withGit(async (rt) => {
|
|
||||||
const [base, diff, stats] = await Promise.all([
|
|
||||||
rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))),
|
|
||||||
rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))),
|
|
||||||
rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(base).toBeTruthy()
|
|
||||||
expect(diff).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
file: weird,
|
|
||||||
status: "modified",
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
expect(stats).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
file: weird,
|
|
||||||
additions: 1,
|
|
||||||
deletions: 1,
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("show() returns empty text for binary blobs", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))
|
|
||||||
await $`git add .`.cwd(tmp.path).quiet()
|
|
||||||
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
|
|
||||||
|
|
||||||
await withGit(async (rt) => {
|
|
||||||
const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat")))
|
|
||||||
expect(text).toBe("")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -8,13 +8,8 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { GlobalBus } from "../../src/bus/global"
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
import { Vcs } from "../../src/project/vcs"
|
import { Vcs } from "../../src/project/vcs"
|
||||||
|
|
||||||
// Skip in CI — native @parcel/watcher binding needed
|
|
||||||
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function withVcs(directory: string, body: () => Promise<void>) {
|
async function withVcs(directory: string, body: () => Promise<void>) {
|
||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
@@ -27,20 +22,8 @@ async function withVcs(directory: string, body: () => Promise<void>) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function withVcsOnly(directory: string, body: () => Promise<void>) {
|
|
||||||
return Instance.provide({
|
|
||||||
directory,
|
|
||||||
fn: async () => {
|
|
||||||
Vcs.init()
|
|
||||||
await body()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
|
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
|
||||||
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
|
|
||||||
|
|
||||||
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
|
|
||||||
function nextBranchUpdate(directory: string, timeout = 10_000) {
|
function nextBranchUpdate(directory: string, timeout = 10_000) {
|
||||||
return new Promise<string | undefined>((resolve, reject) => {
|
return new Promise<string | undefined>((resolve, reject) => {
|
||||||
let settled = false
|
let settled = false
|
||||||
@@ -66,10 +49,6 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describeVcs("Vcs", () => {
|
describeVcs("Vcs", () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
@@ -103,7 +82,8 @@ describeVcs("Vcs", () => {
|
|||||||
const pending = nextBranchUpdate(tmp.path)
|
const pending = nextBranchUpdate(tmp.path)
|
||||||
|
|
||||||
const head = path.join(tmp.path, ".git", "HEAD")
|
const head = path.join(tmp.path, ".git", "HEAD")
|
||||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
await fs.writeFile(head, `ref: refs/heads/${branch}
|
||||||
|
`)
|
||||||
|
|
||||||
const updated = await pending
|
const updated = await pending
|
||||||
expect(updated).toBe(branch)
|
expect(updated).toBe(branch)
|
||||||
@@ -119,7 +99,8 @@ describeVcs("Vcs", () => {
|
|||||||
const pending = nextBranchUpdate(tmp.path)
|
const pending = nextBranchUpdate(tmp.path)
|
||||||
|
|
||||||
const head = path.join(tmp.path, ".git", "HEAD")
|
const head = path.join(tmp.path, ".git", "HEAD")
|
||||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
await fs.writeFile(head, `ref: refs/heads/${branch}
|
||||||
|
`)
|
||||||
|
|
||||||
await pending
|
await pending
|
||||||
const current = await Vcs.branch()
|
const current = await Vcs.branch()
|
||||||
@@ -127,102 +108,3 @@ describeVcs("Vcs", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Vcs diff", () => {
|
|
||||||
afterEach(async () => {
|
|
||||||
await Instance.disposeAll()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("defaultBranch() falls back to main", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
|
||||||
|
|
||||||
await withVcsOnly(tmp.path, async () => {
|
|
||||||
const branch = await Vcs.defaultBranch()
|
|
||||||
expect(branch).toBe("main")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("defaultBranch() uses init.defaultBranch when available", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await $`git branch -M trunk`.cwd(tmp.path).quiet()
|
|
||||||
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
|
|
||||||
|
|
||||||
await withVcsOnly(tmp.path, async () => {
|
|
||||||
const branch = await Vcs.defaultBranch()
|
|
||||||
expect(branch).toBe("trunk")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("detects current branch from the active worktree", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await using wt = await tmpdir()
|
|
||||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
|
||||||
const dir = path.join(wt.path, "feature")
|
|
||||||
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
|
|
||||||
|
|
||||||
await withVcsOnly(dir, async () => {
|
|
||||||
const [branch, base] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
|
||||||
expect(branch).toBe("feature/test")
|
|
||||||
expect(base).toBe("main")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("diff('git') returns uncommitted changes", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8")
|
|
||||||
await $`git add .`.cwd(tmp.path).quiet()
|
|
||||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
|
||||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
|
|
||||||
|
|
||||||
await withVcsOnly(tmp.path, async () => {
|
|
||||||
const diff = await Vcs.diff("git")
|
|
||||||
expect(diff).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
file: "file.txt",
|
|
||||||
status: "modified",
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("diff('git') handles special filenames", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
|
|
||||||
|
|
||||||
await withVcsOnly(tmp.path, async () => {
|
|
||||||
const diff = await Vcs.diff("git")
|
|
||||||
expect(diff).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
file: weird,
|
|
||||||
status: "added",
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("diff('branch') returns changes against default branch", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
|
||||||
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
|
|
||||||
await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
|
|
||||||
await $`git add .`.cwd(tmp.path).quiet()
|
|
||||||
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
|
|
||||||
|
|
||||||
await withVcsOnly(tmp.path, async () => {
|
|
||||||
const diff = await Vcs.diff("branch")
|
|
||||||
expect(diff).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
file: "branch.txt",
|
|
||||||
status: "added",
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ import type {
|
|||||||
TuiSelectSessionResponses,
|
TuiSelectSessionResponses,
|
||||||
TuiShowToastResponses,
|
TuiShowToastResponses,
|
||||||
TuiSubmitPromptResponses,
|
TuiSubmitPromptResponses,
|
||||||
VcsDiffResponses,
|
|
||||||
VcsGetResponses,
|
VcsGetResponses,
|
||||||
WorktreeCreateErrors,
|
WorktreeCreateErrors,
|
||||||
WorktreeCreateInput,
|
WorktreeCreateInput,
|
||||||
@@ -3740,38 +3739,6 @@ export class Vcs extends HeyApiClient {
|
|||||||
...params,
|
...params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get VCS diff
|
|
||||||
*
|
|
||||||
* Retrieve the current git diff for the working tree or against the default branch.
|
|
||||||
*/
|
|
||||||
public diff<ThrowOnError extends boolean = false>(
|
|
||||||
parameters: {
|
|
||||||
directory?: string
|
|
||||||
workspace?: string
|
|
||||||
mode: "git" | "branch"
|
|
||||||
},
|
|
||||||
options?: Options<never, ThrowOnError>,
|
|
||||||
) {
|
|
||||||
const params = buildClientParams(
|
|
||||||
[parameters],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
args: [
|
|
||||||
{ in: "query", key: "directory" },
|
|
||||||
{ in: "query", key: "workspace" },
|
|
||||||
{ in: "query", key: "mode" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
return (options?.client ?? this.client).get<VcsDiffResponses, unknown, ThrowOnError>({
|
|
||||||
url: "/vcs/diff",
|
|
||||||
...options,
|
|
||||||
...params,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Command extends HeyApiClient {
|
export class Command extends HeyApiClient {
|
||||||
|
|||||||
@@ -1994,8 +1994,7 @@ export type Path = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type VcsInfo = {
|
export type VcsInfo = {
|
||||||
branch?: string
|
branch: string
|
||||||
default_branch?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Command = {
|
export type Command = {
|
||||||
@@ -5010,26 +5009,6 @@ export type VcsGetResponses = {
|
|||||||
|
|
||||||
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
|
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
|
||||||
|
|
||||||
export type VcsDiffData = {
|
|
||||||
body?: never
|
|
||||||
path?: never
|
|
||||||
query: {
|
|
||||||
directory?: string
|
|
||||||
workspace?: string
|
|
||||||
mode: "git" | "branch"
|
|
||||||
}
|
|
||||||
url: "/vcs/diff"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VcsDiffResponses = {
|
|
||||||
/**
|
|
||||||
* VCS diff
|
|
||||||
*/
|
|
||||||
200: Array<FileDiff>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]
|
|
||||||
|
|
||||||
export type CommandListData = {
|
export type CommandListData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
|
|
||||||
const open = () => props.open ?? store.open
|
const open = () => props.open ?? store.open
|
||||||
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
||||||
|
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
|
||||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||||
const hasDiffs = () => files().length > 0
|
const hasDiffs = () => files().length > 0
|
||||||
|
|
||||||
@@ -281,10 +282,11 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
<Show when={hasDiffs()} fallback={props.empty}>
|
<Show when={hasDiffs()} fallback={props.empty}>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<Accordion multiple value={open()} onChange={handleChange}>
|
<Accordion multiple value={open()} onChange={handleChange}>
|
||||||
<For each={props.diffs}>
|
<For each={files()}>
|
||||||
{(diff) => {
|
{(file) => {
|
||||||
let wrapper: HTMLDivElement | undefined
|
let wrapper: HTMLDivElement | undefined
|
||||||
const file = diff.file
|
|
||||||
|
const item = createMemo(() => diffs().get(file)!)
|
||||||
|
|
||||||
const expanded = createMemo(() => open().includes(file))
|
const expanded = createMemo(() => open().includes(file))
|
||||||
const force = () => !!store.force[file]
|
const force = () => !!store.force[file]
|
||||||
@@ -292,9 +294,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
|
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
|
||||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||||
|
|
||||||
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
||||||
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
const afterText = () => (typeof item().after === "string" ? item().after : "")
|
||||||
const changedLines = () => diff.additions + diff.deletions
|
const changedLines = () => item().additions + item().deletions
|
||||||
const mediaKind = createMemo(() => mediaKindFromPath(file))
|
const mediaKind = createMemo(() => mediaKindFromPath(file))
|
||||||
|
|
||||||
const tooLarge = createMemo(() => {
|
const tooLarge = createMemo(() => {
|
||||||
@@ -305,9 +307,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isAdded = () =>
|
const isAdded = () =>
|
||||||
diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||||
const isDeleted = () =>
|
const isDeleted = () =>
|
||||||
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||||
|
|
||||||
const selectedLines = createMemo(() => {
|
const selectedLines = createMemo(() => {
|
||||||
const current = selection()
|
const current = selection()
|
||||||
@@ -344,7 +346,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
file,
|
file,
|
||||||
selection,
|
selection,
|
||||||
comment,
|
comment,
|
||||||
preview: selectionPreview(diff, selection),
|
preview: selectionPreview(item(), selection),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onUpdate: ({ id, comment, selection }) => {
|
onUpdate: ({ id, comment, selection }) => {
|
||||||
@@ -353,7 +355,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
file,
|
file,
|
||||||
selection,
|
selection,
|
||||||
comment,
|
comment,
|
||||||
preview: selectionPreview(diff, selection),
|
preview: selectionPreview(item(), selection),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onDelete: (comment) => {
|
onDelete: (comment) => {
|
||||||
@@ -430,7 +432,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
<span data-slot="session-review-change" data-type="added">
|
<span data-slot="session-review-change" data-type="added">
|
||||||
{i18n.t("ui.sessionReview.change.added")}
|
{i18n.t("ui.sessionReview.change.added")}
|
||||||
</span>
|
</span>
|
||||||
<DiffChanges changes={diff} />
|
<DiffChanges changes={item()} />
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={isDeleted()}>
|
<Match when={isDeleted()}>
|
||||||
@@ -444,7 +446,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<DiffChanges changes={diff} />
|
<DiffChanges changes={item()} />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
<span data-slot="session-review-diff-chevron">
|
<span data-slot="session-review-diff-chevron">
|
||||||
@@ -490,7 +492,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
<Dynamic
|
<Dynamic
|
||||||
component={fileComponent}
|
component={fileComponent}
|
||||||
mode="diff"
|
mode="diff"
|
||||||
preloadedDiff={diff.preloaded}
|
preloadedDiff={item().preloaded}
|
||||||
diffStyle={diffStyle()}
|
diffStyle={diffStyle()}
|
||||||
onRendered={() => {
|
onRendered={() => {
|
||||||
props.onDiffRendered?.()
|
props.onDiffRendered?.()
|
||||||
@@ -507,17 +509,17 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
commentedLines={commentedLines()}
|
commentedLines={commentedLines()}
|
||||||
before={{
|
before={{
|
||||||
name: file,
|
name: file,
|
||||||
contents: typeof diff.before === "string" ? diff.before : "",
|
contents: typeof item().before === "string" ? item().before : "",
|
||||||
}}
|
}}
|
||||||
after={{
|
after={{
|
||||||
name: file,
|
name: file,
|
||||||
contents: typeof diff.after === "string" ? diff.after : "",
|
contents: typeof item().after === "string" ? item().after : "",
|
||||||
}}
|
}}
|
||||||
media={{
|
media={{
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
path: file,
|
path: file,
|
||||||
before: diff.before,
|
before: item().before,
|
||||||
after: diff.after,
|
after: item().after,
|
||||||
readFile: props.readFile,
|
readFile: props.readFile,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
export const dict: Record<string, string> = {
|
export const dict: Record<string, string> = {
|
||||||
"ui.sessionReview.title": "Session changes",
|
"ui.sessionReview.title": "Session changes",
|
||||||
"ui.sessionReview.title.git": "Git changes",
|
|
||||||
"ui.sessionReview.title.branch": "Branch changes",
|
|
||||||
"ui.sessionReview.title.lastTurn": "Last turn changes",
|
"ui.sessionReview.title.lastTurn": "Last turn changes",
|
||||||
"ui.sessionReview.diffStyle.unified": "Unified",
|
"ui.sessionReview.diffStyle.unified": "Unified",
|
||||||
"ui.sessionReview.diffStyle.split": "Split",
|
"ui.sessionReview.diffStyle.split": "Split",
|
||||||
|
|||||||
Reference in New Issue
Block a user