Skip to content

Commit 6e94403

Browse files
committed
Improve git state sync and auto-fetch
1 parent aa0b4d2 commit 6e94403

39 files changed

Lines changed: 3748 additions & 189 deletions

docs/help/desktop-guide.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,26 @@
8888

8989
切换到 Git 标签可以看到当前分支名称和文件变更列表。文件前面的图标表示状态(新增、修改、删除等)。
9090

91+
### 获取远程分支
92+
93+
左侧面板标题里的分支名按钮会打开分支选择器,显示本地分支和远程分支。状态栏里的 **Fetch** 按钮会立即执行一次远程 `git fetch`,并刷新分支列表。
94+
95+
当页面处于前台、且当前工作区正显示在界面上时,Coder Studio 还会为这个活动工作区周期性执行远程 fetch,用来让分支选择器里的远程分支保持最新。
96+
97+
#### 高级:调整自动 fetch 周期
98+
99+
高级设置键 `git.autofetchPeriodSec` 控制活动工作区的周期性远程 fetch,默认值是 `180` 秒;设为 `0` 会关闭周期性 fetch,但不会影响打开工作区时的自动 fetch,也不会影响手动点击 **Fetch**
100+
101+
当前设置页还没有这个高级键的图形化入口。如需手动调整,可以直接写入本地运行时数据库的 `user_settings` 表。默认数据库通常位于 `~/.coder-studio/data/coder-studio.db`;如果你通过 `coder-studio config --data-dir ...` 改过数据目录,请使用你自己的数据库路径。
102+
103+
```bash
104+
sqlite3 ~/.coder-studio/data/coder-studio.db "
105+
INSERT INTO user_settings (key, value)
106+
VALUES ('git.autofetchPeriodSec', '60')
107+
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
108+
"
109+
```
110+
91111
### 查看变更内容
92112

93113
点击 Git 列表中的某个文件,中央区域会显示差异视图。
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { execFileSync } from "node:child_process";
2+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3+
import { dirname, join } from "node:path";
4+
import { closeDatabase, openDatabase } from "../../packages/server/src/storage/db.ts";
5+
6+
const WORKSPACE_ID = "ws-git-auto-fetch";
7+
const AUTO_FETCH_PERIOD_SEC = 1;
8+
const WORKSPACE_DIR_NAME = "git-auto-fetch-workspace";
9+
const REMOTE_DIR_NAME = "git-auto-fetch-remote.git";
10+
const CONTRIBUTOR_DIR_NAME = "git-auto-fetch-contributor";
11+
12+
const [, , dbPath, workspacesRoot] = process.argv;
13+
14+
if (!dbPath || !workspacesRoot) {
15+
throw new Error("Usage: tsx seed-git-auto-fetch-db.ts <db-path> <workspaces-root>");
16+
}
17+
18+
const sandboxRoot = dirname(dbPath);
19+
const remotePath = join(sandboxRoot, REMOTE_DIR_NAME);
20+
const contributorPath = join(sandboxRoot, CONTRIBUTOR_DIR_NAME);
21+
22+
mkdirSync(dirname(dbPath), { recursive: true });
23+
mkdirSync(workspacesRoot, { recursive: true });
24+
rmSync(dbPath, { force: true });
25+
rmSync(remotePath, { recursive: true, force: true });
26+
rmSync(contributorPath, { recursive: true, force: true });
27+
28+
const runGit = (args: string[], cwd: string) => {
29+
execFileSync("git", args, {
30+
cwd,
31+
env: {
32+
...process.env,
33+
GIT_AUTHOR_NAME: "Coder Studio E2E",
34+
GIT_AUTHOR_EMAIL: "e2e@coder-studio.test",
35+
GIT_COMMITTER_NAME: "Coder Studio E2E",
36+
GIT_COMMITTER_EMAIL: "e2e@coder-studio.test",
37+
},
38+
stdio: "pipe",
39+
});
40+
};
41+
42+
const createWorkspaceDir = (dirName: string): string => {
43+
const workspacePath = join(workspacesRoot, dirName);
44+
rmSync(workspacePath, { recursive: true, force: true });
45+
mkdirSync(workspacePath, { recursive: true });
46+
mkdirSync(join(workspacePath, "src"), { recursive: true });
47+
writeFileSync(join(workspacePath, "README.md"), `# ${dirName}\n`);
48+
writeFileSync(join(workspacePath, "src", "index.ts"), "export const ready = true;\n");
49+
50+
runGit(["init", "--initial-branch=main"], workspacePath);
51+
runGit(["add", "."], workspacePath);
52+
runGit(["commit", "-m", "init"], workspacePath);
53+
54+
return workspacePath;
55+
};
56+
57+
const workspacePath = createWorkspaceDir(WORKSPACE_DIR_NAME);
58+
runGit(["init", "--bare", remotePath], sandboxRoot);
59+
runGit(["remote", "add", "origin", remotePath], workspacePath);
60+
runGit(["push", "-u", "origin", "main"], workspacePath);
61+
runGit(["clone", remotePath, contributorPath], sandboxRoot);
62+
runGit(["config", "user.name", "Coder Studio E2E"], contributorPath);
63+
runGit(["config", "user.email", "e2e@coder-studio.test"], contributorPath);
64+
65+
const db = openDatabase(dbPath);
66+
const now = Date.now();
67+
68+
try {
69+
db.prepare(
70+
`INSERT INTO workspaces (id, path, target_runtime, wsl_distro, opened_at, last_active_at, ui_state)
71+
VALUES (?, ?, ?, ?, ?, ?, ?)`
72+
).run(
73+
WORKSPACE_ID,
74+
workspacePath,
75+
"native",
76+
null,
77+
now - 10_000,
78+
now,
79+
JSON.stringify({
80+
leftPanelWidth: 280,
81+
bottomPanelHeight: 200,
82+
focusMode: false,
83+
})
84+
);
85+
86+
db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run(
87+
"git.autofetchPeriodSec",
88+
JSON.stringify(AUTO_FETCH_PERIOD_SEC)
89+
);
90+
91+
console.log(
92+
JSON.stringify({
93+
dbPath,
94+
workspaceId: WORKSPACE_ID,
95+
workspacePath,
96+
remotePath,
97+
contributorPath,
98+
autoFetchPeriodSec: AUTO_FETCH_PERIOD_SEC,
99+
})
100+
);
101+
} finally {
102+
closeDatabase(db);
103+
}

e2e/specs/git-auto-fetch.spec.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { type ChildProcess, spawn, spawnSync } from "node:child_process";
2+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { expect, test } from "@playwright/test";
6+
7+
const HOST = "127.0.0.1";
8+
const SERVER_PORT = 43185;
9+
const WEB_PORT = 53185;
10+
const BACKEND_HTTP_URL = `http://${HOST}:${SERVER_PORT}`;
11+
const BASE_URL = `http://${HOST}:${WEB_PORT}`;
12+
const REMOTE_BRANCH_NAME = "feature/auto-fetch-remote";
13+
const REMOTE_BRANCH_REF = `origin/${REMOTE_BRANCH_NAME}`;
14+
15+
let sandboxDir: string;
16+
let dbPath: string;
17+
let runtimeDir: string;
18+
let workspacesRoot: string;
19+
let contributorDir: string;
20+
let backendProcess: ChildProcess | undefined;
21+
let webProcess: ChildProcess | undefined;
22+
23+
const startProcess = (
24+
command: string,
25+
args: string[],
26+
options: {
27+
cwd: string;
28+
env?: NodeJS.ProcessEnv;
29+
}
30+
): ChildProcess => {
31+
const child = spawn(command, args, {
32+
cwd: options.cwd,
33+
env: {
34+
...process.env,
35+
...options.env,
36+
},
37+
stdio: ["ignore", "pipe", "pipe"],
38+
});
39+
40+
child.stdout?.on("data", () => {});
41+
child.stderr?.on("data", () => {});
42+
child.on("error", (error) => {
43+
throw error;
44+
});
45+
46+
return child;
47+
};
48+
49+
const waitForHttp = async (url: string, timeoutMs = 30000): Promise<void> => {
50+
const start = Date.now();
51+
52+
while (Date.now() - start < timeoutMs) {
53+
try {
54+
const response = await fetch(url);
55+
if (response.ok) {
56+
return;
57+
}
58+
} catch {
59+
// keep polling
60+
}
61+
62+
await new Promise((resolve) => setTimeout(resolve, 250));
63+
}
64+
65+
throw new Error(`Timed out waiting for ${url}`);
66+
};
67+
68+
const runGit = (args: string[], cwd: string) => {
69+
const result = spawnSyncSafe("git", args, cwd);
70+
if (result.status !== 0) {
71+
throw new Error(result.stderr || `git ${args.join(" ")} failed`);
72+
}
73+
};
74+
75+
const spawnSyncSafe = (command: string, args: string[], cwd: string) => {
76+
return spawnSync(command, args, {
77+
cwd,
78+
env: {
79+
...process.env,
80+
GIT_AUTHOR_NAME: "Coder Studio E2E",
81+
GIT_AUTHOR_EMAIL: "e2e@coder-studio.test",
82+
GIT_COMMITTER_NAME: "Coder Studio E2E",
83+
GIT_COMMITTER_EMAIL: "e2e@coder-studio.test",
84+
},
85+
encoding: "utf8",
86+
});
87+
};
88+
89+
const pushRemoteBranch = () => {
90+
if (!existsSync(join(contributorDir, ".git"))) {
91+
throw new Error(`Contributor repo missing at ${contributorDir}`);
92+
}
93+
94+
writeFileSync(join(contributorDir, "feature.txt"), "feature\n");
95+
runGit(["checkout", "-b", REMOTE_BRANCH_NAME], contributorDir);
96+
runGit(["add", "."], contributorDir);
97+
runGit(["commit", "-m", "add remote branch"], contributorDir);
98+
runGit(["push", "-u", "origin", REMOTE_BRANCH_NAME], contributorDir);
99+
};
100+
101+
test.describe("git auto-fetch acceptance", () => {
102+
test.beforeAll(async () => {
103+
sandboxDir = mkdtempSync(join(tmpdir(), "coder-studio-git-auto-fetch-e2e-"));
104+
dbPath = join(sandboxDir, "coder-studio.db");
105+
runtimeDir = join(sandboxDir, "runtime");
106+
workspacesRoot = join(sandboxDir, "workspaces");
107+
contributorDir = join(sandboxDir, "git-auto-fetch-contributor");
108+
109+
mkdirSync(runtimeDir, { recursive: true });
110+
mkdirSync(workspacesRoot, { recursive: true });
111+
112+
const seed = spawn(
113+
"pnpm",
114+
["exec", "tsx", "e2e/fixtures/seed-git-auto-fetch-db.ts", dbPath, workspacesRoot],
115+
{
116+
cwd: "/home/spencer/workspace/coder-studio",
117+
env: process.env,
118+
stdio: ["ignore", "pipe", "pipe"],
119+
}
120+
);
121+
122+
await new Promise<void>((resolve, reject) => {
123+
let stderr = "";
124+
seed.stderr?.on("data", (chunk) => {
125+
stderr += chunk.toString();
126+
});
127+
seed.on("exit", (code) => {
128+
if (code === 0) {
129+
resolve();
130+
return;
131+
}
132+
reject(new Error(stderr || `seed exited with code ${code}`));
133+
});
134+
seed.on("error", reject);
135+
});
136+
137+
backendProcess = startProcess("pnpm", ["exec", "tsx", "packages/server/src/server.ts"], {
138+
cwd: "/home/spencer/workspace/coder-studio",
139+
env: {
140+
HOST,
141+
PORT: String(SERVER_PORT),
142+
DATA_DIR: dbPath,
143+
RUNTIME_DIR: runtimeDir,
144+
NO_AUTH: "true",
145+
},
146+
});
147+
148+
await waitForHttp(`${BACKEND_HTTP_URL}/healthz`);
149+
150+
webProcess = startProcess(
151+
"pnpm",
152+
["exec", "vite", "--host", HOST, "--port", String(WEB_PORT)],
153+
{
154+
cwd: "/home/spencer/workspace/coder-studio/packages/web",
155+
env: {
156+
NODE_ENV: "development",
157+
VITE_BACKEND_HTTP_URL: BACKEND_HTTP_URL,
158+
VITE_BACKEND_WS_URL: `ws://${HOST}:${SERVER_PORT}/ws`,
159+
},
160+
}
161+
);
162+
163+
await waitForHttp(`${BASE_URL}/`);
164+
});
165+
166+
test.afterAll(async () => {
167+
const kill = async (child: ChildProcess | undefined) => {
168+
if (!child || child.killed) {
169+
return;
170+
}
171+
172+
child.kill("SIGTERM");
173+
await new Promise((resolve) => child.once("exit", resolve));
174+
};
175+
176+
await kill(webProcess);
177+
await kill(backendProcess);
178+
rmSync(sandboxDir, { recursive: true, force: true });
179+
});
180+
181+
test.use({
182+
baseURL: BASE_URL,
183+
});
184+
185+
test("discovers a new remote branch via periodic auto-fetch and updates the manual fetch tooltip", async ({
186+
page,
187+
}) => {
188+
await page.goto("/workspace");
189+
await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 });
190+
191+
const branchButton = page.getByRole("button", {
192+
name: "Open branch switcher for main",
193+
});
194+
await expect(branchButton).toBeVisible({ timeout: 20000 });
195+
196+
await branchButton.click();
197+
await expect(page.locator(".branch-quick-pick-overlay")).toBeVisible();
198+
await expect(page.locator(".branch-quick-pick-name").filter({ hasText: /^main$/ })).toHaveCount(
199+
1
200+
);
201+
await expect(
202+
page
203+
.locator(".branch-quick-pick-name")
204+
.filter({ hasText: new RegExp(`^${REMOTE_BRANCH_REF}$`) })
205+
).toHaveCount(0);
206+
207+
pushRemoteBranch();
208+
209+
await expect(page.getByText(REMOTE_BRANCH_REF)).toBeVisible({ timeout: 15000 });
210+
211+
await page.keyboard.press("Escape");
212+
await expect(page.locator(".branch-quick-pick-overlay")).toHaveCount(0);
213+
214+
const fetchButton = page.locator(".git-status-bar > button").first();
215+
await expect(fetchButton).toHaveAttribute("title", /^(Never fetched|)$/);
216+
217+
await fetchButton.click();
218+
219+
await expect
220+
.poll(async () => await fetchButton.getAttribute("title"), {
221+
timeout: 15000,
222+
})
223+
.toMatch(/^(Last fetched | )/);
224+
});
225+
});

0 commit comments

Comments
 (0)