Skip to content

Commit 8a95be4

Browse files
authored
fix(windows): git path resolution for modified files across Git Bash, MSYS2, and Cygwin (anomalyco#16422)
1 parent c42c5a0 commit 8a95be4

13 files changed

Lines changed: 285 additions & 101 deletions

File tree

packages/app/e2e/actions.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from "node:fs/promises"
33
import os from "node:os"
44
import path from "node:path"
55
import { execSync } from "node:child_process"
6-
import { modKey, serverUrl } from "./utils"
6+
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
77
import {
88
dropdownMenuTriggerSelector,
99
dropdownMenuContentSelector,
@@ -18,7 +18,6 @@ import {
1818
workspaceItemSelector,
1919
workspaceMenuTriggerSelector,
2020
} from "./selectors"
21-
import type { createSdk } from "./utils"
2221

2322
export async function defocus(page: Page) {
2423
await page
@@ -190,7 +189,7 @@ export async function createTestProject() {
190189
stdio: "ignore",
191190
})
192191

193-
return root
192+
return resolveDirectory(root)
194193
}
195194

196195
export async function cleanupTestProject(directory: string) {

packages/app/e2e/projects/workspace-new-session.spec.ts

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,26 @@ function slugFromUrl(url: string) {
99
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
1010
}
1111

12+
async function waitSlug(page: Page, skip: string[] = []) {
13+
let prev = ""
14+
await expect
15+
.poll(
16+
() => {
17+
const slug = slugFromUrl(page.url())
18+
if (!slug) return ""
19+
if (skip.includes(slug)) return ""
20+
if (slug !== prev) {
21+
prev = slug
22+
return ""
23+
}
24+
return slug
25+
},
26+
{ timeout: 45_000 },
27+
)
28+
.not.toBe("")
29+
return slugFromUrl(page.url())
30+
}
31+
1232
async function waitWorkspaceReady(page: Page, slug: string) {
1333
await openSidebar(page)
1434
await expect
@@ -31,20 +51,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
3151
await openSidebar(page)
3252
await page.getByRole("button", { name: "New workspace" }).first().click()
3353

34-
await expect
35-
.poll(
36-
() => {
37-
const slug = slugFromUrl(page.url())
38-
if (!slug) return ""
39-
if (slug === root) return ""
40-
if (seen.includes(slug)) return ""
41-
return slug
42-
},
43-
{ timeout: 45_000 },
44-
)
45-
.not.toBe("")
46-
47-
const slug = slugFromUrl(page.url())
54+
const slug = await waitSlug(page, [root, ...seen])
4855
const directory = base64Decode(slug)
4956
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
5057
return { slug, directory }
@@ -60,12 +67,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
6067
await expect(button).toBeVisible()
6168
await button.click({ force: true })
6269

63-
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
64-
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
70+
const next = await waitSlug(page)
71+
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
72+
return next
6573
}
6674

6775
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
68-
await openWorkspaceNewSession(page, slug)
76+
const next = await openWorkspaceNewSession(page, slug)
6977

7078
const prompt = page.locator(promptSelector)
7179
await expect(prompt).toBeVisible()
@@ -76,13 +84,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
7684
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
7785
await prompt.press("Enter")
7886

79-
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
87+
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
8088
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
8189

8290
const sessionID = sessionIDFromUrl(page.url())
8391
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
84-
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
85-
return sessionID
92+
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
93+
return { sessionID, slug: next }
8694
}
8795

8896
async function sessionDirectory(directory: string, sessionID: string) {
@@ -114,17 +122,17 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
114122
await waitWorkspaceReady(page, second.slug)
115123

116124
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
117-
sessions.push(firstSession)
125+
sessions.push(firstSession.sessionID)
118126

119127
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
120-
sessions.push(secondSession)
128+
sessions.push(secondSession.sessionID)
121129

122130
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
123-
sessions.push(thirdSession)
131+
sessions.push(thirdSession.sessionID)
124132

125-
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
126-
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
127-
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
133+
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
134+
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
135+
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
128136
} finally {
129137
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
130138
await Promise.all(

packages/app/e2e/projects/workspaces.spec.ts

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,34 @@ function slugFromUrl(url: string) {
2222
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
2323
}
2424

25-
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
26-
const rootSlug = project.slug
27-
await openSidebar(page)
28-
29-
await setWorkspacesEnabled(page, rootSlug, true)
30-
31-
await page.getByRole("button", { name: "New workspace" }).first().click()
25+
async function waitSlug(page: Page, skip: string[] = []) {
26+
let prev = ""
3227
await expect
3328
.poll(
3429
() => {
3530
const slug = slugFromUrl(page.url())
36-
return slug.length > 0 && slug !== rootSlug
31+
if (!slug) return ""
32+
if (skip.includes(slug)) return ""
33+
if (slug !== prev) {
34+
prev = slug
35+
return ""
36+
}
37+
return slug
3738
},
3839
{ timeout: 45_000 },
3940
)
40-
.toBe(true)
41+
.not.toBe("")
42+
return slugFromUrl(page.url())
43+
}
44+
45+
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
46+
const rootSlug = project.slug
47+
await openSidebar(page)
48+
49+
await setWorkspacesEnabled(page, rootSlug, true)
4150

42-
const slug = slugFromUrl(page.url())
51+
await page.getByRole("button", { name: "New workspace" }).first().click()
52+
const slug = await waitSlug(page, [rootSlug])
4353
const dir = base64Decode(slug)
4454

4555
await openSidebar(page)
@@ -91,18 +101,7 @@ test("can create a workspace", async ({ page, withProject }) => {
91101
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
92102

93103
await page.getByRole("button", { name: "New workspace" }).first().click()
94-
95-
await expect
96-
.poll(
97-
() => {
98-
const currentSlug = slugFromUrl(page.url())
99-
return currentSlug.length > 0 && currentSlug !== slug
100-
},
101-
{ timeout: 45_000 },
102-
)
103-
.toBe(true)
104-
105-
const workspaceSlug = slugFromUrl(page.url())
104+
const workspaceSlug = await waitSlug(page, [slug])
106105
const workspaceDir = base64Decode(workspaceSlug)
107106

108107
await openSidebar(page)
@@ -279,7 +278,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
279278
await clickMenuItem(menu, /^Delete$/i, { force: true })
280279
await confirmDialog(page, /^Delete workspace$/i)
281280

282-
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
281+
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
283282

284283
await expect
285284
.poll(

packages/app/e2e/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ export function createSdk(directory?: string) {
1414
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
1515
}
1616

17+
export async function resolveDirectory(directory: string) {
18+
return createSdk(directory)
19+
.path.get()
20+
.then((x) => x.data?.directory ?? directory)
21+
}
22+
1723
export async function getWorktree() {
1824
const sdk = createSdk()
1925
const result = await sdk.path.get()
Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
1-
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
1+
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
22
import { createStore } from "solid-js/store"
3-
import { useNavigate, useParams } from "@solidjs/router"
3+
import { useLocation, useNavigate, useParams } from "@solidjs/router"
44
import { SDKProvider } from "@/context/sdk"
55
import { SyncProvider, useSync } from "@/context/sync"
66
import { LocalProvider } from "@/context/local"
7+
import { useGlobalSDK } from "@/context/global-sdk"
78

89
import { DataProvider } from "@opencode-ai/ui/context"
10+
import { base64Encode } from "@opencode-ai/util/encode"
911
import { decode64 } from "@/utils/base64"
1012
import { showToast } from "@opencode-ai/ui/toast"
1113
import { useLanguage } from "@/context/language"
12-
1314
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
14-
const params = useParams()
1515
const navigate = useNavigate()
1616
const sync = useSync()
17+
const slug = createMemo(() => base64Encode(props.directory))
1718

1819
return (
1920
<DataProvider
2021
data={sync.data}
2122
directory={props.directory}
22-
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
23-
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
23+
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
24+
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
2425
>
2526
<LocalProvider>{props.children}</LocalProvider>
2627
</DataProvider>
@@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
3031
export default function Layout(props: ParentProps) {
3132
const params = useParams()
3233
const navigate = useNavigate()
34+
const location = useLocation()
3335
const language = useLanguage()
34-
const [store, setStore] = createStore({ invalid: "" })
35-
const directory = createMemo(() => {
36-
return decode64(params.dir) ?? ""
37-
})
36+
const globalSDK = useGlobalSDK()
37+
const directory = createMemo(() => decode64(params.dir) ?? "")
38+
const [state, setState] = createStore({ invalid: "", resolved: "" })
3839

3940
createEffect(() => {
4041
if (!params.dir) return
41-
if (directory()) return
42-
if (store.invalid === params.dir) return
43-
setStore("invalid", params.dir)
44-
showToast({
45-
variant: "error",
46-
title: language.t("common.requestFailed"),
47-
description: language.t("directory.error.invalidUrl"),
48-
})
49-
navigate("/", { replace: true })
42+
const raw = directory()
43+
if (!raw) {
44+
if (state.invalid === params.dir) return
45+
setState("invalid", params.dir)
46+
showToast({
47+
variant: "error",
48+
title: language.t("common.requestFailed"),
49+
description: language.t("directory.error.invalidUrl"),
50+
})
51+
navigate("/", { replace: true })
52+
return
53+
}
54+
55+
const current = params.dir
56+
globalSDK
57+
.createClient({
58+
directory: raw,
59+
throwOnError: true,
60+
})
61+
.path.get()
62+
.then((x) => {
63+
if (params.dir !== current) return
64+
const next = x.data?.directory ?? raw
65+
batch(() => {
66+
setState("invalid", "")
67+
setState("resolved", next)
68+
})
69+
if (next === raw) return
70+
const path = location.pathname.slice(current.length + 1)
71+
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
72+
})
73+
.catch(() => {
74+
if (params.dir !== current) return
75+
batch(() => {
76+
setState("invalid", "")
77+
setState("resolved", raw)
78+
})
79+
})
5080
})
81+
5182
return (
52-
<Show when={directory()}>
53-
<SDKProvider directory={directory}>
54-
<SyncProvider>
55-
<DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
56-
</SyncProvider>
57-
</SDKProvider>
83+
<Show when={state.resolved}>
84+
{(resolved) => (
85+
<SDKProvider directory={resolved}>
86+
<SyncProvider>
87+
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
88+
</SyncProvider>
89+
</SDKProvider>
90+
)}
5891
</Show>
5992
)
6093
}

packages/opencode/src/cli/cmd/tui/thread.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,10 @@ export const TuiThreadCommand = cmd({
111111
}
112112

113113
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
114-
const root = process.env.PWD ?? process.cwd()
115-
const cwd = args.project ? path.resolve(root, args.project) : process.cwd()
114+
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
115+
const cwd = args.project
116+
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
117+
: root
116118
const file = await target()
117119
try {
118120
process.chdir(cwd)

packages/opencode/src/project/instance.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,14 @@ function track(directory: string, next: Promise<Context>) {
6262

6363
export const Instance = {
6464
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
65-
let existing = cache.get(input.directory)
65+
const directory = Filesystem.resolve(input.directory)
66+
let existing = cache.get(directory)
6667
if (!existing) {
67-
Log.Default.info("creating instance", { directory: input.directory })
68+
Log.Default.info("creating instance", { directory })
6869
existing = track(
69-
input.directory,
70+
directory,
7071
boot({
71-
directory: input.directory,
72+
directory,
7273
init: input.init,
7374
}),
7475
)
@@ -103,11 +104,12 @@ export const Instance = {
103104
return State.create(() => Instance.directory, init, dispose)
104105
},
105106
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
106-
Log.Default.info("reloading instance", { directory: input.directory })
107-
await State.dispose(input.directory)
108-
cache.delete(input.directory)
109-
const next = track(input.directory, boot(input))
110-
emit(input.directory)
107+
const directory = Filesystem.resolve(input.directory)
108+
Log.Default.info("reloading instance", { directory })
109+
await State.dispose(directory)
110+
cache.delete(directory)
111+
const next = track(directory, boot({ ...input, directory }))
112+
emit(directory)
111113
return await next
112114
},
113115
async dispose() {

0 commit comments

Comments
 (0)