feat: restore git-backed review modes (#20845)
This commit is contained in:
@@ -248,7 +248,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?.branch) input.vcsCache.setStore("value", next)
|
if (next) 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,8 +494,10 @@ describe("applyDirectoryEvent", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("updates vcs branch in store and cache", () => {
|
test("updates vcs branch in store and cache", () => {
|
||||||
const [store, setStore] = createStore(baseState())
|
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||||
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
const [cacheStore, setCacheStore] = createStore({
|
||||||
|
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" } },
|
||||||
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.vcs).toEqual({ branch: "feature/test" })
|
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||||
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||||
})
|
})
|
||||||
|
|
||||||
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 = { branch: props.branch }
|
const next = { ...input.store.vcs, 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,6 +535,8 @@ 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 { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { FileDiff, 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 {
|
||||||
@@ -68,6 +68,9 @@ type FollowupItem = FollowupDraft & { id: string }
|
|||||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||||
const emptyFollowups: FollowupItem[] = []
|
const emptyFollowups: FollowupItem[] = []
|
||||||
|
|
||||||
|
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,15 +430,16 @@ 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 reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||||
const hasReview = createMemo(() => reviewCount() > 0)
|
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||||
|
const canReview = createMemo(() => !!sync.project)
|
||||||
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,
|
hasReview: canReview,
|
||||||
})
|
})
|
||||||
const contextOpen = tabState.contextOpen
|
const contextOpen = tabState.contextOpen
|
||||||
const openedTabs = tabState.openedTabs
|
const openedTabs = tabState.openedTabs
|
||||||
@@ -458,6 +462,12 @@ 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[],
|
||||||
@@ -511,11 +521,22 @@ 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: "session" as "session" | "turn",
|
changes: "git" as ChangeMode,
|
||||||
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] = persisted(
|
const [followup, setFollowup] = persisted(
|
||||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
@@ -549,6 +570,68 @@ export default function Page() {
|
|||||||
let todoTimer: number | undefined
|
let todoTimer: 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()
|
||||||
@@ -564,7 +647,42 @@ export default function Page() {
|
|||||||
}, desktopReviewOpen())
|
}, desktopReviewOpen())
|
||||||
|
|
||||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||||
|
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"
|
||||||
@@ -630,13 +748,7 @@ export default function Page() {
|
|||||||
scrollToMessage(msgs[targetIndex], "auto")
|
scrollToMessage(msgs[targetIndex], "auto")
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffsReady = createMemo(() => {
|
const sessionEmptyKey = 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"
|
||||||
@@ -790,13 +902,46 @@ export default function Page() {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
() => {
|
() => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
setStore("changes", "session")
|
setStore("changes", "git")
|
||||||
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,
|
||||||
@@ -919,6 +1064,40 @@ 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)
|
||||||
@@ -965,21 +1144,23 @@ export default function Page() {
|
|||||||
loadFile: file.load,
|
loadFile: file.load,
|
||||||
})
|
})
|
||||||
|
|
||||||
const changesOptions = ["session", "turn"] as const
|
|
||||||
const changesOptionsList = [...changesOptions]
|
|
||||||
|
|
||||||
const changesTitle = () => {
|
const changesTitle = () => {
|
||||||
if (!hasReview()) {
|
if (!canReview()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const label = (option: ChangeMode) => {
|
||||||
|
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||||
|
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||||
|
if (option === "session") return language.t("ui.sessionReview.title")
|
||||||
|
return language.t("ui.sessionReview.title.lastTurn")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
options={changesOptionsList}
|
options={changesOptions()}
|
||||||
current={store.changes}
|
current={store.changes}
|
||||||
label={(option) =>
|
label={label}
|
||||||
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"
|
||||||
@@ -988,20 +1169,34 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyTurn = () => (
|
const empty = (text: string) => (
|
||||||
<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">{language.t("session.review.noChanges")}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
const reviewEmptyText = createMemo(() => {
|
||||||
if (store.changes === "turn") return emptyTurn()
|
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())
|
||||||
|
})
|
||||||
|
|
||||||
if (hasReview() && !diffsReady()) {
|
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||||
|
if (store.changes === "git" || store.changes === "branch") {
|
||||||
|
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||||
|
return empty(reviewEmptyText())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.changes === "turn") {
|
||||||
|
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 (reviewEmptyKey() === "session.review.noVcs") {
|
if (sessionEmptyKey() === "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">
|
||||||
@@ -1021,7 +1216,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">{language.t(reviewEmptyKey())}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1128,7 +1323,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 (!diffsReady()) return
|
if (!reviewReady()) return
|
||||||
|
|
||||||
const attempt = (count: number) => {
|
const attempt = (count: number) => {
|
||||||
if (tree.pendingDiff !== pending) return
|
if (tree.pendingDiff !== pending) return
|
||||||
@@ -1169,10 +1364,7 @@ export default function Page() {
|
|||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
const wants = isDesktop()
|
if (!wantsReview()) return
|
||||||
? 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
|
||||||
|
|
||||||
@@ -1181,13 +1373,7 @@ 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)
|
||||||
@@ -1867,6 +2053,12 @@ 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,6 +8,7 @@ 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"
|
||||||
|
|
||||||
@@ -18,7 +19,6 @@ 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"
|
||||||
@@ -26,6 +26,12 @@ 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
|
||||||
@@ -33,12 +39,11 @@ 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 { params, sessionKey, tabs, view } = useSessionLayout()
|
const { sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
@@ -53,24 +58,7 @@ export function SessionSidePanel(props: {
|
|||||||
})
|
})
|
||||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||||
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
|
||||||
@@ -81,7 +69,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 diffs()) {
|
for (const diff of props.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"
|
||||||
|
|
||||||
@@ -135,7 +123,7 @@ export function SessionSidePanel(props: {
|
|||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab,
|
normalizeTab,
|
||||||
review: reviewTab,
|
review: reviewTab,
|
||||||
hasReview,
|
hasReview: props.canReview,
|
||||||
})
|
})
|
||||||
const contextOpen = tabState.contextOpen
|
const contextOpen = tabState.contextOpen
|
||||||
const openedTabs = tabState.openedTabs
|
const openedTabs = tabState.openedTabs
|
||||||
@@ -240,12 +228,12 @@ export function SessionSidePanel(props: {
|
|||||||
onCleanup(stop)
|
onCleanup(stop)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={reviewTab()}>
|
<Show when={reviewTab() && props.canReview()}>
|
||||||
<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={hasReview()}>
|
<Show when={props.hasReview()}>
|
||||||
<div>{reviewCount()}</div>
|
<div>{props.reviewCount()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
@@ -304,7 +292,7 @@ export function SessionSidePanel(props: {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={reviewTab()}>
|
<Show when={reviewTab() && props.canReview()}>
|
||||||
<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>
|
||||||
@@ -378,8 +366,10 @@ 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" }}>
|
||||||
{reviewCount()}{" "}
|
{props.reviewCount()}{" "}
|
||||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
{language.t(
|
||||||
|
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")}
|
||||||
@@ -387,9 +377,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={hasReview()}>
|
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||||
<Show
|
<Show
|
||||||
when={diffsReady()}
|
when={props.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")}
|
||||||
@@ -408,11 +398,7 @@ export function SessionSidePanel(props: {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>{empty(props.empty())}</Match>
|
||||||
{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">
|
||||||
|
|||||||
@@ -52,11 +52,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
return sync.session.get(id)
|
return sync.session.get(id)
|
||||||
}
|
}
|
||||||
const hasReview = () => {
|
const hasReview = () => !!params.id
|
||||||
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(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
const info = (await Git.run(["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(args, { cwd: Instance.worktree })
|
const result = await Git.run(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(args, { cwd: Instance.worktree })
|
const result = await Git.run(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(args, { cwd: Instance.worktree })
|
const gitStatus = (args: string[]) => Git.run(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(["remote"], { cwd: Instance.worktree })).text().trim()
|
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||||
if (!remotes.split("\n").includes(remoteName)) {
|
if (!remotes.split("\n").includes(remoteName)) {
|
||||||
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
await Git.run(["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(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||||
cwd: Instance.worktree,
|
cwd: Instance.worktree,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 { AppFileSystem } from "@/filesystem"
|
||||||
import { git } from "@/util/git"
|
import { Git } from "@/git"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
@@ -419,7 +419,7 @@ export namespace File {
|
|||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const diffOutput = (
|
const diffOutput = (
|
||||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
})
|
})
|
||||||
).text()
|
).text()
|
||||||
@@ -439,7 +439,7 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const untrackedOutput = (
|
const untrackedOutput = (
|
||||||
await git(
|
await Git.run(
|
||||||
[
|
[
|
||||||
"-c",
|
"-c",
|
||||||
"core.fsmonitor=false",
|
"core.fsmonitor=false",
|
||||||
@@ -472,7 +472,7 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deletedOutput = (
|
const deletedOutput = (
|
||||||
await git(
|
await Git.run(
|
||||||
[
|
[
|
||||||
"-c",
|
"-c",
|
||||||
"core.fsmonitor=false",
|
"core.fsmonitor=false",
|
||||||
@@ -560,17 +560,17 @@ export namespace File {
|
|||||||
if (Instance.project.vcs === "git") {
|
if (Instance.project.vcs === "git") {
|
||||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||||
let diff = (
|
let diff = (
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||||
).text()
|
).text()
|
||||||
if (!diff.trim()) {
|
if (!diff.trim()) {
|
||||||
diff = (
|
diff = (
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
await Git.run(["-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(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
const original = (await Git.run(["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"
|
||||||
@@ -132,7 +132,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(["rev-parse", "--git-dir"], {
|
Git.run(["rev-parse", "--git-dir"], {
|
||||||
cwd: Instance.project.worktree,
|
cwd: Instance.project.worktree,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
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.defaultLayer))
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
export async function run(args: string[], opts: Options) {
|
||||||
|
return runPromise((git) => git.run(args, opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function branch(cwd: string) {
|
||||||
|
return runPromise((git) => git.branch(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prefix(cwd: string) {
|
||||||
|
return runPromise((git) => git.prefix(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function defaultBranch(cwd: string) {
|
||||||
|
return runPromise((git) => git.defaultBranch(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasHead(cwd: string) {
|
||||||
|
return runPromise((git) => git.hasHead(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeBase(cwd: string, base: string, head?: string) {
|
||||||
|
return runPromise((git) => git.mergeBase(cwd, base, head))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function show(cwd: string, ref: string, file: string, prefix?: string) {
|
||||||
|
return runPromise((git) => git.show(cwd, ref, file, prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function status(cwd: string) {
|
||||||
|
return runPromise((git) => git.status(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diff(cwd: string, ref: string) {
|
||||||
|
return runPromise((git) => git.diff(cwd, ref))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stats(cwd: string, ref: string) {
|
||||||
|
return runPromise((git) => git.stats(cwd, ref))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,111 @@
|
|||||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
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 * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|
||||||
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 { 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",
|
||||||
@@ -24,6 +118,7 @@ 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",
|
||||||
@@ -33,57 +128,45 @@ 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, Bus.Service | ChildProcessSpawner.ChildProcessSpawner> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | 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 spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
|
|
||||||
const git = Effect.fnUntraced(
|
|
||||||
function* (args: string[], opts: { cwd: string }) {
|
|
||||||
const handle = yield* spawner.spawn(
|
|
||||||
ChildProcess.make("git", args, { cwd: opts.cwd, extendEnv: true, stdin: "ignore" }),
|
|
||||||
)
|
|
||||||
const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
|
|
||||||
const code = yield* handle.exitCode
|
|
||||||
return { code, text }
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), text: "" })),
|
|
||||||
)
|
|
||||||
|
|
||||||
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 }
|
return { current: undefined, root: undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBranch = Effect.fnUntraced(function* () {
|
const get = Effect.fnUntraced(function* () {
|
||||||
const result = yield* git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: ctx.worktree })
|
return yield* git.branch(ctx.directory)
|
||||||
if (result.code !== 0) return undefined
|
|
||||||
const text = result.text.trim()
|
|
||||||
return text || undefined
|
|
||||||
})
|
})
|
||||||
|
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||||
const value = {
|
concurrency: 2,
|
||||||
current: yield* getBranch(),
|
})
|
||||||
}
|
const value = { current, root }
|
||||||
log.info("initialized", { branch: value.current })
|
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||||
|
|
||||||
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(() =>
|
Stream.runForEach((_evt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = yield* getBranch()
|
const next = yield* get()
|
||||||
if (next !== value.current) {
|
if (next !== value.current) {
|
||||||
log.info("branch changed", { from: value.current, to: next })
|
log.info("branch changed", { from: value.current, to: next })
|
||||||
value.current = next
|
value.current = next
|
||||||
@@ -106,19 +189,52 @@ 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(Layer.provide(Bus.layer), Layer.provide(CrossSpawnSpawner.defaultLayer))
|
const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Bus.layer),
|
||||||
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export function init() {
|
export async function init() {
|
||||||
return runPromise((svc) => svc.init())
|
return runPromise((svc) => svc.init())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function branch() {
|
export async function branch() {
|
||||||
return runPromise((svc) => svc.branch())
|
return runPromise((svc) => svc.branch())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function defaultBranch() {
|
||||||
|
return runPromise((svc) => svc.defaultBranch())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diff(mode: Mode) {
|
||||||
|
return runPromise((svc) => svc.diff(mode))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describeRoute, resolver } from "hono-openapi"
|
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { proxy } from "hono/proxy"
|
import { proxy } from "hono/proxy"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
@@ -16,6 +16,7 @@ import { Command } from "../command"
|
|||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { QuestionRoutes } from "./routes/question"
|
import { QuestionRoutes } from "./routes/question"
|
||||||
import { PermissionRoutes } from "./routes/permission"
|
import { PermissionRoutes } from "./routes/permission"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
import { ProjectRoutes } from "./routes/project"
|
import { ProjectRoutes } from "./routes/project"
|
||||||
import { SessionRoutes } from "./routes/session"
|
import { SessionRoutes } from "./routes/session"
|
||||||
import { PtyRoutes } from "./routes/pty"
|
import { PtyRoutes } from "./routes/pty"
|
||||||
@@ -134,12 +135,40 @@ export const InstanceRoutes = (app?: Hono) =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const branch = await Vcs.branch()
|
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
||||||
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({
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import path from "path"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
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 "@/util/git"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
|
import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
|
||||||
export namespace Storage {
|
export namespace Storage {
|
||||||
const log = Log.create({ service: "storage" })
|
const log = Log.create({ service: "storage" })
|
||||||
@@ -111,7 +111,7 @@ export namespace Storage {
|
|||||||
if (!worktree) continue
|
if (!worktree) continue
|
||||||
if (!(yield* fs.isDir(worktree))) continue
|
if (!(yield* fs.isDir(worktree))) continue
|
||||||
const result = yield* Effect.promise(() =>
|
const result = yield* Effect.promise(() =>
|
||||||
git(["rev-list", "--max-parents=0", "--all"], {
|
Git.run(["rev-list", "--max-parents=0", "--all"], {
|
||||||
cwd: worktree,
|
cwd: worktree,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
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)),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,7 @@ import { Slug } from "@opencode-ai/util/slug"
|
|||||||
import { errorMessage } from "../util/error"
|
import { errorMessage } from "../util/error"
|
||||||
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, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import { NodePath } from "@effect/platform-node"
|
import { NodePath } from "@effect/platform-node"
|
||||||
@@ -515,56 +516,24 @@ export namespace Worktree {
|
|||||||
|
|
||||||
const worktreePath = entry.path
|
const worktreePath = entry.path
|
||||||
|
|
||||||
const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
|
const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree))
|
||||||
if (remoteList.code !== 0) {
|
if (!base) {
|
||||||
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" })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remoteBranch) {
|
const sep = base.ref.indexOf("/")
|
||||||
|
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, remoteBranch],
|
["fetch", remote, branch],
|
||||||
{ cwd: Instance.worktree },
|
{ cwd: Instance.worktree },
|
||||||
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
|
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* gitExpect(
|
yield* gitExpect(
|
||||||
["reset", "--hard", target],
|
["reset", "--hard", base.ref],
|
||||||
{ 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" }),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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,8 +8,13 @@ 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,
|
||||||
@@ -22,8 +27,20 @@ async function withVcs(directory: string, body: () => Promise<void>) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
|
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 } } }
|
||||||
|
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
|
||||||
@@ -49,6 +66,10 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describeVcs("Vcs", () => {
|
describeVcs("Vcs", () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
@@ -82,11 +103,7 @@ 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(
|
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||||
head,
|
|
||||||
`ref: refs/heads/${branch}
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const updated = await pending
|
const updated = await pending
|
||||||
expect(updated).toBe(branch)
|
expect(updated).toBe(branch)
|
||||||
@@ -102,11 +119,7 @@ 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(
|
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||||
head,
|
|
||||||
`ref: refs/heads/${branch}
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
|
|
||||||
await pending
|
await pending
|
||||||
const current = await Vcs.branch()
|
const current = await Vcs.branch()
|
||||||
@@ -114,3 +127,102 @@ 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,6 +175,7 @@ import type {
|
|||||||
TuiSelectSessionResponses,
|
TuiSelectSessionResponses,
|
||||||
TuiShowToastResponses,
|
TuiShowToastResponses,
|
||||||
TuiSubmitPromptResponses,
|
TuiSubmitPromptResponses,
|
||||||
|
VcsDiffResponses,
|
||||||
VcsGetResponses,
|
VcsGetResponses,
|
||||||
WorktreeCreateErrors,
|
WorktreeCreateErrors,
|
||||||
WorktreeCreateInput,
|
WorktreeCreateInput,
|
||||||
@@ -3848,6 +3849,38 @@ 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 {
|
||||||
|
|||||||
@@ -2003,6 +2003,7 @@ export type Path = {
|
|||||||
|
|
||||||
export type VcsInfo = {
|
export type VcsInfo = {
|
||||||
branch?: string
|
branch?: string
|
||||||
|
default_branch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Command = {
|
export type Command = {
|
||||||
@@ -5065,6 +5066,26 @@ 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
|
||||||
|
|||||||
@@ -359,11 +359,10 @@ 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={files()}>
|
<For each={props.diffs}>
|
||||||
{(file) => {
|
{(diff) => {
|
||||||
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 mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||||
@@ -372,9 +371,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
const comments = createMemo(() => grouped().get(file) ?? [])
|
const comments = createMemo(() => grouped().get(file) ?? [])
|
||||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||||
|
|
||||||
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
||||||
const afterText = () => (typeof item().after === "string" ? item().after : "")
|
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
||||||
const changedLines = () => item().additions + item().deletions
|
const changedLines = () => diff.additions + diff.deletions
|
||||||
const mediaKind = createMemo(() => mediaKindFromPath(file))
|
const mediaKind = createMemo(() => mediaKindFromPath(file))
|
||||||
|
|
||||||
const tooLarge = createMemo(() => {
|
const tooLarge = createMemo(() => {
|
||||||
@@ -385,9 +384,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isAdded = () =>
|
const isAdded = () =>
|
||||||
item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||||
const isDeleted = () =>
|
const isDeleted = () =>
|
||||||
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||||
|
|
||||||
const selectedLines = createMemo(() => {
|
const selectedLines = createMemo(() => {
|
||||||
const current = selection()
|
const current = selection()
|
||||||
@@ -425,7 +424,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
file,
|
file,
|
||||||
selection,
|
selection,
|
||||||
comment,
|
comment,
|
||||||
preview: selectionPreview(item(), selection),
|
preview: selectionPreview(diff, selection),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onUpdate: ({ id, comment, selection }) => {
|
onUpdate: ({ id, comment, selection }) => {
|
||||||
@@ -434,7 +433,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
file,
|
file,
|
||||||
selection,
|
selection,
|
||||||
comment,
|
comment,
|
||||||
preview: selectionPreview(item(), selection),
|
preview: selectionPreview(diff, selection),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onDelete: (comment) => {
|
onDelete: (comment) => {
|
||||||
@@ -513,7 +512,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={item()} />
|
<DiffChanges changes={diff} />
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={isDeleted()}>
|
<Match when={isDeleted()}>
|
||||||
@@ -527,7 +526,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<DiffChanges changes={item()} />
|
<DiffChanges changes={diff} />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
<span data-slot="session-review-diff-chevron">
|
<span data-slot="session-review-diff-chevron">
|
||||||
@@ -582,7 +581,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
<Dynamic
|
<Dynamic
|
||||||
component={fileComponent}
|
component={fileComponent}
|
||||||
mode="diff"
|
mode="diff"
|
||||||
preloadedDiff={item().preloaded}
|
preloadedDiff={diff.preloaded}
|
||||||
diffStyle={diffStyle()}
|
diffStyle={diffStyle()}
|
||||||
onRendered={() => {
|
onRendered={() => {
|
||||||
props.onDiffRendered?.()
|
props.onDiffRendered?.()
|
||||||
@@ -599,17 +598,17 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
commentedLines={commentedLines()}
|
commentedLines={commentedLines()}
|
||||||
before={{
|
before={{
|
||||||
name: file,
|
name: file,
|
||||||
contents: typeof item().before === "string" ? item().before : "",
|
contents: typeof diff.before === "string" ? diff.before : "",
|
||||||
}}
|
}}
|
||||||
after={{
|
after={{
|
||||||
name: file,
|
name: file,
|
||||||
contents: typeof item().after === "string" ? item().after : "",
|
contents: typeof diff.after === "string" ? diff.after : "",
|
||||||
}}
|
}}
|
||||||
media={{
|
media={{
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
path: file,
|
path: file,
|
||||||
before: item().before,
|
before: diff.before,
|
||||||
after: item().after,
|
after: diff.after,
|
||||||
readFile: props.readFile,
|
readFile: props.readFile,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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