Support native Git (experimental) (#338)
This commit is contained in:
@@ -20,9 +20,7 @@ test("delete app", async ({ po }) => {
|
|||||||
await po.page.getByRole("button", { name: "Delete App" }).click();
|
await po.page.getByRole("button", { name: "Delete App" }).click();
|
||||||
|
|
||||||
// Make sure the app is deleted
|
// Make sure the app is deleted
|
||||||
await expect(async () => {
|
await po.isCurrentAppNameNone();
|
||||||
expect(await po.getCurrentAppName()).toBe("(no app selected)");
|
|
||||||
}).toPass();
|
|
||||||
expect(fs.existsSync(appPath)).toBe(false);
|
expect(fs.existsSync(appPath)).toBe(false);
|
||||||
expect(po.getAppListItem({ appName })).not.toBeVisible();
|
expect(po.getAppListItem({ appName })).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
avoid AI_RULES auto-prompt
|
||||||
1
e2e-tests/fixtures/import-app/version-integrity/a.txt
Normal file
1
e2e-tests/fixtures/import-app/version-integrity/a.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
a
|
||||||
1
e2e-tests/fixtures/import-app/version-integrity/b.txt
Normal file
1
e2e-tests/fixtures/import-app/version-integrity/b.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dir/c.txt
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
this file should be deleted
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
before-edit
|
||||||
10
e2e-tests/fixtures/version-integrity-add-edit-delete.md
Normal file
10
e2e-tests/fixtures/version-integrity-add-edit-delete.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Deleting a file
|
||||||
|
|
||||||
|
<dyad-delete path="to-be-deleted.txt"></dyad-delete>
|
||||||
|
<dyad-write path="new-file.js" description="new file">
|
||||||
|
new-file
|
||||||
|
end of new-file
|
||||||
|
</dyad-write>
|
||||||
|
<dyad-write path="to-be-edited.txt" description="editing file">
|
||||||
|
after-edit
|
||||||
|
</dyad-write>
|
||||||
2
e2e-tests/fixtures/version-integrity-move-file.md
Normal file
2
e2e-tests/fixtures/version-integrity-move-file.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Moving a file
|
||||||
|
<dyad-rename from="dir/c.txt" to="new-dir/d.txt"></dyad-rename>
|
||||||
@@ -15,7 +15,7 @@ export const Timeout = {
|
|||||||
MEDIUM: os.platform() === "win32" ? 30_000 : 15_000,
|
MEDIUM: os.platform() === "win32" ? 30_000 : 15_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
class PageObject {
|
export class PageObject {
|
||||||
private userDataDir: string;
|
private userDataDir: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -26,11 +26,17 @@ class PageObject {
|
|||||||
this.userDataDir = userDataDir;
|
this.userDataDir = userDataDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setUp({ autoApprove = false }: { autoApprove?: boolean } = {}) {
|
async setUp({
|
||||||
|
autoApprove = false,
|
||||||
|
nativeGit = false,
|
||||||
|
}: { autoApprove?: boolean; nativeGit?: boolean } = {}) {
|
||||||
await this.goToSettingsTab();
|
await this.goToSettingsTab();
|
||||||
if (autoApprove) {
|
if (autoApprove) {
|
||||||
await this.toggleAutoApprove();
|
await this.toggleAutoApprove();
|
||||||
}
|
}
|
||||||
|
if (nativeGit) {
|
||||||
|
await this.toggleNativeGit();
|
||||||
|
}
|
||||||
await this.setUpTestProvider();
|
await this.setUpTestProvider();
|
||||||
await this.setUpTestModel();
|
await this.setUpTestModel();
|
||||||
|
|
||||||
@@ -67,6 +73,37 @@ class PageObject {
|
|||||||
// await page.getByRole('button', { name: 'Select Folder' }).press('Escape');
|
// await page.getByRole('button', { name: 'Select Folder' }).press('Escape');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async snapshotAppFiles({ name }: { name?: string } = {}) {
|
||||||
|
const appPath = await this.getCurrentAppPath();
|
||||||
|
if (!appPath || !fs.existsSync(appPath)) {
|
||||||
|
throw new Error(`App path does not exist: ${appPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(() => {
|
||||||
|
const filesData = generateAppFilesSnapshotData(appPath, appPath, [
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
// Avoid snapshotting lock files because they are getting generated
|
||||||
|
// automatically and cause noise, and not super important anyways.
|
||||||
|
"package-lock.json",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sort by relative path to ensure deterministic output
|
||||||
|
filesData.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||||
|
|
||||||
|
const snapshotContent = filesData
|
||||||
|
.map((file) => `=== ${file.relativePath} ===\n${file.content}`)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
expect(snapshotContent).toMatchSnapshot(name);
|
||||||
|
} else {
|
||||||
|
expect(snapshotContent).toMatchSnapshot();
|
||||||
|
}
|
||||||
|
}).toPass();
|
||||||
|
}
|
||||||
|
|
||||||
async snapshotMessages({
|
async snapshotMessages({
|
||||||
replaceDumpPath = false,
|
replaceDumpPath = false,
|
||||||
}: { replaceDumpPath?: boolean } = {}) {
|
}: { replaceDumpPath?: boolean } = {}) {
|
||||||
@@ -134,9 +171,10 @@ class PageObject {
|
|||||||
return this.page.getByTestId("preview-iframe-element");
|
return this.page.getByTestId("preview-iframe-element");
|
||||||
}
|
}
|
||||||
|
|
||||||
async snapshotPreview() {
|
async snapshotPreview({ name }: { name?: string } = {}) {
|
||||||
const iframe = this.getPreviewIframeElement();
|
const iframe = this.getPreviewIframeElement();
|
||||||
await expect(iframe.contentFrame().locator("body")).toMatchAriaSnapshot({
|
await expect(iframe.contentFrame().locator("body")).toMatchAriaSnapshot({
|
||||||
|
name,
|
||||||
timeout: Timeout.LONG,
|
timeout: Timeout.LONG,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -299,7 +337,21 @@ class PageObject {
|
|||||||
return this.page.getByTestId(`app-list-item-${appName}`);
|
return this.page.getByTestId(`app-list-item-${appName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isCurrentAppNameNone() {
|
||||||
|
await expect(async () => {
|
||||||
|
await expect(this.getTitleBarAppNameButton()).toContainText(
|
||||||
|
"no app selected",
|
||||||
|
);
|
||||||
|
}).toPass();
|
||||||
|
}
|
||||||
|
|
||||||
async getCurrentAppName() {
|
async getCurrentAppName() {
|
||||||
|
// Make sure to wait for the app to be set to avoid a race condition.
|
||||||
|
await expect(async () => {
|
||||||
|
await expect(this.getTitleBarAppNameButton()).not.toContainText(
|
||||||
|
"no app selected",
|
||||||
|
);
|
||||||
|
}).toPass();
|
||||||
return (await this.getTitleBarAppNameButton().textContent())?.replace(
|
return (await this.getTitleBarAppNameButton().textContent())?.replace(
|
||||||
"App: ",
|
"App: ",
|
||||||
"",
|
"",
|
||||||
@@ -338,6 +390,10 @@ class PageObject {
|
|||||||
await this.page.getByRole("switch", { name: "Auto-approve" }).click();
|
await this.page.getByRole("switch", { name: "Auto-approve" }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async toggleNativeGit() {
|
||||||
|
await this.page.getByRole("switch", { name: "Enable Native Git" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
async snapshotSettings() {
|
async snapshotSettings() {
|
||||||
const settings = path.join(this.userDataDir, "user-settings.json");
|
const settings = path.join(this.userDataDir, "user-settings.json");
|
||||||
const settingsContent = fs.readFileSync(settings, "utf-8");
|
const settingsContent = fs.readFileSync(settings, "utf-8");
|
||||||
@@ -588,3 +644,48 @@ function prettifyDump(
|
|||||||
})
|
})
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FileSnapshotData {
|
||||||
|
relativePath: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAppFilesSnapshotData(
|
||||||
|
currentPath: string,
|
||||||
|
basePath: string,
|
||||||
|
ignorePatterns: string[],
|
||||||
|
): FileSnapshotData[] {
|
||||||
|
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
||||||
|
let files: FileSnapshotData[] = [];
|
||||||
|
|
||||||
|
// Sort entries for deterministic order
|
||||||
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(currentPath, entry.name);
|
||||||
|
if (ignorePatterns.includes(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files = files.concat(
|
||||||
|
generateAppFilesSnapshotData(entryPath, basePath, ignorePatterns),
|
||||||
|
);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
const relativePath = path.relative(basePath, entryPath);
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(entryPath, "utf-8");
|
||||||
|
files.push({ relativePath, content });
|
||||||
|
} catch (error) {
|
||||||
|
// Could be a binary file or permission issue, log and add a placeholder
|
||||||
|
const e = error as Error;
|
||||||
|
console.warn(`Could not read file ${entryPath}: ${e.message}`);
|
||||||
|
files.push({
|
||||||
|
relativePath,
|
||||||
|
content: `[Error reading file: ${e.message}]`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|||||||
7
e2e-tests/snapshots/switch_versions.spec.ts_v1
Normal file
7
e2e-tests/snapshots/switch_versions.spec.ts_v1
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
- region "Notifications (F8)":
|
||||||
|
- list
|
||||||
|
- region "Notifications alt+T"
|
||||||
|
- heading "Welcome to Your Blank App" [level=1]
|
||||||
|
- paragraph: Start building your amazing project here!
|
||||||
|
- link "Made with Dyad":
|
||||||
|
- /url: https://www.dyad.sh/
|
||||||
4
e2e-tests/snapshots/switch_versions.spec.ts_v2
Normal file
4
e2e-tests/snapshots/switch_versions.spec.ts_v2
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
- region "Notifications (F8)":
|
||||||
|
- list
|
||||||
|
- region "Notifications alt+T"
|
||||||
|
- text: Testing:write-index!
|
||||||
18
e2e-tests/snapshots/version_integrity.spec.ts_v1
Normal file
18
e2e-tests/snapshots/version_integrity.spec.ts_v1
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
=== a.txt ===
|
||||||
|
a
|
||||||
|
|
||||||
|
=== AI_RULES.md ===
|
||||||
|
avoid AI_RULES auto-prompt
|
||||||
|
|
||||||
|
|
||||||
|
=== b.txt ===
|
||||||
|
b
|
||||||
|
|
||||||
|
=== dir/c.txt ===
|
||||||
|
dir/c.txt
|
||||||
|
|
||||||
|
=== to-be-deleted.txt ===
|
||||||
|
this file should be deleted
|
||||||
|
|
||||||
|
=== to-be-edited.txt ===
|
||||||
|
before-edit
|
||||||
19
e2e-tests/snapshots/version_integrity.spec.ts_v2
Normal file
19
e2e-tests/snapshots/version_integrity.spec.ts_v2
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
=== a.txt ===
|
||||||
|
a
|
||||||
|
|
||||||
|
=== AI_RULES.md ===
|
||||||
|
avoid AI_RULES auto-prompt
|
||||||
|
|
||||||
|
|
||||||
|
=== b.txt ===
|
||||||
|
b
|
||||||
|
|
||||||
|
=== dir/c.txt ===
|
||||||
|
dir/c.txt
|
||||||
|
|
||||||
|
=== new-file.js ===
|
||||||
|
new-file
|
||||||
|
end of new-file
|
||||||
|
|
||||||
|
=== to-be-edited.txt ===
|
||||||
|
after-edit
|
||||||
19
e2e-tests/snapshots/version_integrity.spec.ts_v3
Normal file
19
e2e-tests/snapshots/version_integrity.spec.ts_v3
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
=== a.txt ===
|
||||||
|
a
|
||||||
|
|
||||||
|
=== AI_RULES.md ===
|
||||||
|
avoid AI_RULES auto-prompt
|
||||||
|
|
||||||
|
|
||||||
|
=== b.txt ===
|
||||||
|
b
|
||||||
|
|
||||||
|
=== new-dir/d.txt ===
|
||||||
|
dir/c.txt
|
||||||
|
|
||||||
|
=== new-file.js ===
|
||||||
|
new-file
|
||||||
|
end of new-file
|
||||||
|
|
||||||
|
=== to-be-edited.txt ===
|
||||||
|
after-edit
|
||||||
@@ -1,22 +1,30 @@
|
|||||||
import { testSkipIfWindows } from "./helpers/test_helper";
|
import { PageObject, testSkipIfWindows } from "./helpers/test_helper";
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
testSkipIfWindows("switch versions", async ({ po }) => {
|
const runSwitchVersionTest = async (po: PageObject, nativeGit: boolean) => {
|
||||||
await po.setUp({ autoApprove: true });
|
await po.setUp({ autoApprove: true, nativeGit });
|
||||||
await po.sendPrompt("tc=write-index");
|
await po.sendPrompt("tc=write-index");
|
||||||
|
|
||||||
await po.snapshotPreview();
|
await po.snapshotPreview({ name: `v2` });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await po.page.getByRole("button", { name: "Version" }).textContent(),
|
await po.page.getByRole("button", { name: "Version" }).textContent(),
|
||||||
).toBe("Version 2");
|
).toBe("Version 2");
|
||||||
await po.page.getByRole("button", { name: "Version" }).click();
|
await po.page.getByRole("button", { name: "Version" }).click();
|
||||||
await po.page.getByText("Init Dyad app Undo").click();
|
await po.page.getByText("Init Dyad app Undo").click();
|
||||||
await po.snapshotPreview();
|
await po.snapshotPreview({ name: `v1` });
|
||||||
|
|
||||||
await po.page.getByRole("button", { name: "Undo to latest version" }).click();
|
await po.page.getByRole("button", { name: "Undo to latest version" }).click();
|
||||||
// Should be same as the previous snapshot, but just to be sure.
|
// Should be same as the previous snapshot, but just to be sure.
|
||||||
await po.snapshotPreview();
|
await po.snapshotPreview({ name: `v1` });
|
||||||
|
|
||||||
await expect(po.page.getByText("Version 3")).toBeVisible();
|
await expect(po.page.getByText("Version 3")).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
testSkipIfWindows("switch versions", async ({ po }) => {
|
||||||
|
await runSwitchVersionTest(po, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
testSkipIfWindows("switch versions with native git", async ({ po }) => {
|
||||||
|
await runSwitchVersionTest(po, true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ test("create next.js app", async ({ po }) => {
|
|||||||
await po.clickRestart();
|
await po.clickRestart();
|
||||||
|
|
||||||
// This can be pretty slow because it's waiting for the app to build.
|
// This can be pretty slow because it's waiting for the app to build.
|
||||||
await expect(po.getPreviewIframeElement()).toBeVisible({ timeout: 50_000 });
|
await expect(po.getPreviewIframeElement()).toBeVisible({ timeout: 100_000 });
|
||||||
await po.snapshotPreview();
|
await po.snapshotPreview();
|
||||||
});
|
});
|
||||||
|
|||||||
51
e2e-tests/version_integrity.spec.ts
Normal file
51
e2e-tests/version_integrity.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { PageObject, testSkipIfWindows } from "./helpers/test_helper";
|
||||||
|
|
||||||
|
import * as eph from "electron-playwright-helpers";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const runVersionIntegrityTest = async (po: PageObject, nativeGit: boolean) => {
|
||||||
|
await po.setUp({ autoApprove: true, nativeGit });
|
||||||
|
|
||||||
|
// Importing a simple app with a few files.
|
||||||
|
await po.page.getByRole("button", { name: "Import App" }).click();
|
||||||
|
await eph.stubDialog(po.electronApp, "showOpenDialog", {
|
||||||
|
filePaths: [
|
||||||
|
path.join(__dirname, "fixtures", "import-app", "version-integrity"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await po.page.getByRole("button", { name: "Select Folder" }).click();
|
||||||
|
await po.page.getByRole("textbox", { name: "Enter new app name" }).click();
|
||||||
|
await po.page
|
||||||
|
.getByRole("textbox", { name: "Enter new app name" })
|
||||||
|
.fill("version-integrity-app");
|
||||||
|
await po.page.getByRole("button", { name: "Import" }).click();
|
||||||
|
|
||||||
|
// Initial snapshot
|
||||||
|
await po.snapshotAppFiles({ name: "v1" });
|
||||||
|
|
||||||
|
// Add a file and delete a file
|
||||||
|
await po.sendPrompt("tc=version-integrity-add-edit-delete");
|
||||||
|
await po.snapshotAppFiles({ name: "v2" });
|
||||||
|
|
||||||
|
// Move a file
|
||||||
|
await po.sendPrompt("tc=version-integrity-move-file");
|
||||||
|
await po.snapshotAppFiles({ name: "v3" });
|
||||||
|
|
||||||
|
// Open version pane
|
||||||
|
await po.page.getByRole("button", { name: "Version 3" }).click();
|
||||||
|
await po.page.getByText("Init Dyad app Undo").click();
|
||||||
|
await po.snapshotAppFiles({ name: "v1" });
|
||||||
|
|
||||||
|
await po.page.getByRole("button", { name: "Undo to latest version" }).click();
|
||||||
|
// Should be same as the previous snapshot, but just to be sure.
|
||||||
|
await po.snapshotAppFiles({ name: "v1" });
|
||||||
|
};
|
||||||
|
|
||||||
|
testSkipIfWindows("version integrity (git isomorphic)", async ({ po }) => {
|
||||||
|
await runVersionIntegrityTest(po, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
testSkipIfWindows("version integrity (git native)", async ({ po }) => {
|
||||||
|
await runVersionIntegrityTest(po, true);
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ import { getEnvVar } from "../utils/read_env";
|
|||||||
import { readSettings } from "../../main/settings";
|
import { readSettings } from "../../main/settings";
|
||||||
|
|
||||||
import fixPath from "fix-path";
|
import fixPath from "fix-path";
|
||||||
import { getGitAuthor } from "../utils/git_author";
|
|
||||||
import killPort from "kill-port";
|
import killPort from "kill-port";
|
||||||
import util from "util";
|
import util from "util";
|
||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
@@ -33,6 +33,7 @@ import { getLanguageModelProviders } from "../shared/language_model_helpers";
|
|||||||
import { startProxy } from "../utils/start_proxy_server";
|
import { startProxy } from "../utils/start_proxy_server";
|
||||||
import { Worker } from "worker_threads";
|
import { Worker } from "worker_threads";
|
||||||
import { createFromTemplate } from "./createFromTemplate";
|
import { createFromTemplate } from "./createFromTemplate";
|
||||||
|
import { gitCommit } from "../utils/git_utils";
|
||||||
|
|
||||||
const logger = log.scope("app_handlers");
|
const logger = log.scope("app_handlers");
|
||||||
const handle = createLoggedHandler(logger);
|
const handle = createLoggedHandler(logger);
|
||||||
@@ -207,11 +208,9 @@ export function registerAppHandlers() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create initial commit
|
// Create initial commit
|
||||||
const commitHash = await git.commit({
|
const commitHash = await gitCommit({
|
||||||
fs: fs,
|
path: fullAppPath,
|
||||||
dir: fullAppPath,
|
|
||||||
message: "Init Dyad app",
|
message: "Init Dyad app",
|
||||||
author: await getGitAuthor(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update chat with initial commit hash
|
// Update chat with initial commit hash
|
||||||
@@ -521,11 +520,9 @@ export function registerAppHandlers() {
|
|||||||
filepath: filePath,
|
filepath: filePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
await git.commit({
|
await gitCommit({
|
||||||
fs,
|
path: appPath,
|
||||||
dir: appPath,
|
|
||||||
message: `Updated ${filePath}`,
|
message: `Updated ${filePath}`,
|
||||||
author: await getGitAuthor(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { db } from "@/db";
|
|||||||
import { chats } from "@/db/schema";
|
import { chats } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import git from "isomorphic-git";
|
import git from "isomorphic-git";
|
||||||
import { getGitAuthor } from "../utils/git_author";
|
|
||||||
import { ImportAppParams, ImportAppResult } from "../ipc_types";
|
import { ImportAppParams, ImportAppResult } from "../ipc_types";
|
||||||
import { copyDirectoryRecursive } from "../utils/file_utils";
|
import { copyDirectoryRecursive } from "../utils/file_utils";
|
||||||
|
import { gitCommit } from "../utils/git_utils";
|
||||||
|
|
||||||
const logger = log.scope("import-handlers");
|
const logger = log.scope("import-handlers");
|
||||||
const handle = createLoggedHandler(logger);
|
const handle = createLoggedHandler(logger);
|
||||||
@@ -114,11 +115,9 @@ export function registerImportHandlers() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create initial commit
|
// Create initial commit
|
||||||
await git.commit({
|
await gitCommit({
|
||||||
fs: fs,
|
path: destPath,
|
||||||
dir: destPath,
|
|
||||||
message: "Init Dyad app",
|
message: "Init Dyad app",
|
||||||
author: await getGitAuthor(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import type { Version, BranchResult } from "../ipc_types";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { getDyadAppPath } from "../../paths/paths";
|
import { getDyadAppPath } from "../../paths/paths";
|
||||||
import git from "isomorphic-git";
|
import git, { type ReadCommitResult } from "isomorphic-git";
|
||||||
import { promises as fsPromises } from "node:fs";
|
|
||||||
import { withLock } from "../utils/lock_utils";
|
import { withLock } from "../utils/lock_utils";
|
||||||
import { getGitAuthor } from "../utils/git_author";
|
|
||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
import { createLoggedHandler } from "./safe_handle";
|
import { createLoggedHandler } from "./safe_handle";
|
||||||
|
import { gitCheckout, gitCommit, gitStageToRevert } from "../utils/git_utils";
|
||||||
|
|
||||||
const logger = log.scope("version_handlers");
|
const logger = log.scope("version_handlers");
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ export function registerVersionHandlers() {
|
|||||||
depth: 10_000, // Limit to last 10_000 commits for performance
|
depth: 10_000, // Limit to last 10_000 commits for performance
|
||||||
});
|
});
|
||||||
|
|
||||||
return commits.map((commit) => ({
|
return commits.map((commit: ReadCommitResult) => ({
|
||||||
oid: commit.oid,
|
oid: commit.oid,
|
||||||
message: commit.commit.message,
|
message: commit.commit.message,
|
||||||
timestamp: commit.commit.author.timestamp,
|
timestamp: commit.commit.author.timestamp,
|
||||||
@@ -102,65 +101,19 @@ export function registerVersionHandlers() {
|
|||||||
|
|
||||||
const appPath = getDyadAppPath(app.path);
|
const appPath = getDyadAppPath(app.path);
|
||||||
|
|
||||||
await git.checkout({
|
await gitCheckout({
|
||||||
fs,
|
path: appPath,
|
||||||
dir: appPath,
|
|
||||||
ref: "main",
|
ref: "main",
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
// Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory
|
|
||||||
const matrix = await git.statusMatrix({
|
|
||||||
fs,
|
|
||||||
dir: appPath,
|
|
||||||
ref: previousVersionId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process each file to revert to the state in previousVersionId
|
await gitStageToRevert({
|
||||||
for (const [filepath, headStatus, workdirStatus] of matrix) {
|
path: appPath,
|
||||||
const fullPath = path.join(appPath, filepath);
|
targetOid: previousVersionId,
|
||||||
|
|
||||||
// If file exists in HEAD (previous version)
|
|
||||||
if (headStatus === 1) {
|
|
||||||
// If file doesn't exist or has changed in working directory, restore it from the target commit
|
|
||||||
if (workdirStatus !== 1) {
|
|
||||||
const { blob } = await git.readBlob({
|
|
||||||
fs,
|
|
||||||
dir: appPath,
|
|
||||||
oid: previousVersionId,
|
|
||||||
filepath,
|
|
||||||
});
|
|
||||||
await fsPromises.mkdir(path.dirname(fullPath), {
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
await fsPromises.writeFile(fullPath, Buffer.from(blob));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If file doesn't exist in HEAD but exists in working directory, delete it
|
|
||||||
else if (headStatus === 0 && workdirStatus !== 0) {
|
|
||||||
if (fs.existsSync(fullPath)) {
|
|
||||||
await fsPromises.unlink(fullPath);
|
|
||||||
await git.remove({
|
|
||||||
fs,
|
|
||||||
dir: appPath,
|
|
||||||
filepath: filepath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage all changes
|
|
||||||
await git.add({
|
|
||||||
fs,
|
|
||||||
dir: appPath,
|
|
||||||
filepath: ".",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a revert commit
|
await gitCommit({
|
||||||
await git.commit({
|
path: appPath,
|
||||||
fs,
|
|
||||||
dir: appPath,
|
|
||||||
message: `Reverted all changes back to version ${previousVersionId}`,
|
message: `Reverted all changes back to version ${previousVersionId}`,
|
||||||
author: await getGitAuthor(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find the chat and message associated with the commit hash
|
// Find the chat and message associated with the commit hash
|
||||||
@@ -221,9 +174,8 @@ export function registerVersionHandlers() {
|
|||||||
|
|
||||||
const appPath = getDyadAppPath(app.path);
|
const appPath = getDyadAppPath(app.path);
|
||||||
|
|
||||||
await git.checkout({
|
await gitCheckout({
|
||||||
fs,
|
path: appPath,
|
||||||
dir: appPath,
|
|
||||||
ref: versionId,
|
ref: versionId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { getDyadAppPath } from "../../paths/paths";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import git from "isomorphic-git";
|
import git from "isomorphic-git";
|
||||||
|
|
||||||
import { getGitAuthor } from "../utils/git_author";
|
|
||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
import { executeAddDependency } from "./executeAddDependency";
|
import { executeAddDependency } from "./executeAddDependency";
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
} from "../../supabase_admin/supabase_management_client";
|
} from "../../supabase_admin/supabase_management_client";
|
||||||
import { isServerFunction } from "../../supabase_admin/supabase_utils";
|
import { isServerFunction } from "../../supabase_admin/supabase_utils";
|
||||||
import { SqlQuery } from "../../lib/schemas";
|
import { SqlQuery } from "../../lib/schemas";
|
||||||
|
import { gitCommit } from "../utils/git_utils";
|
||||||
|
|
||||||
const readFile = fs.promises.readFile;
|
const readFile = fs.promises.readFile;
|
||||||
const logger = log.scope("response_processor");
|
const logger = log.scope("response_processor");
|
||||||
@@ -460,11 +460,9 @@ export async function processFullResponseActions(
|
|||||||
? `[dyad] ${chatSummary} - ${changes.join(", ")}`
|
? `[dyad] ${chatSummary} - ${changes.join(", ")}`
|
||||||
: `[dyad] ${changes.join(", ")}`;
|
: `[dyad] ${changes.join(", ")}`;
|
||||||
// Use chat summary, if provided, or default for commit message
|
// Use chat summary, if provided, or default for commit message
|
||||||
let commitHash = await git.commit({
|
let commitHash = await gitCommit({
|
||||||
fs,
|
path: appPath,
|
||||||
dir: appPath,
|
|
||||||
message,
|
message,
|
||||||
author: await getGitAuthor(),
|
|
||||||
});
|
});
|
||||||
logger.log(`Successfully committed changes: ${changes.join(", ")}`);
|
logger.log(`Successfully committed changes: ${changes.join(", ")}`);
|
||||||
|
|
||||||
@@ -482,11 +480,9 @@ export async function processFullResponseActions(
|
|||||||
filepath: ".",
|
filepath: ".",
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
commitHash = await git.commit({
|
commitHash = await gitCommit({
|
||||||
fs,
|
path: appPath,
|
||||||
dir: appPath,
|
|
||||||
message: message + " + extra files edited outside of Dyad",
|
message: message + " + extra files edited outside of Dyad",
|
||||||
author: await getGitAuthor(),
|
|
||||||
amend: true,
|
amend: true,
|
||||||
});
|
});
|
||||||
logger.log(
|
logger.log(
|
||||||
|
|||||||
140
src/ipc/utils/git_utils.ts
Normal file
140
src/ipc/utils/git_utils.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { getGitAuthor } from "./git_author";
|
||||||
|
import git from "isomorphic-git";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { promises as fsPromises } from "node:fs";
|
||||||
|
import pathModule from "node:path";
|
||||||
|
import { exec } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { readSettings } from "../../main/settings";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export async function gitCommit({
|
||||||
|
path,
|
||||||
|
message,
|
||||||
|
amend,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
|
amend?: boolean;
|
||||||
|
}): Promise<string> {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (settings.enableNativeGit) {
|
||||||
|
let command = `git -C "${path}" commit -m "${message.replace(/"/g, '\\"')}"`;
|
||||||
|
if (amend) {
|
||||||
|
command += " --amend";
|
||||||
|
}
|
||||||
|
await execAsync(command);
|
||||||
|
const { stdout } = await execAsync(`git -C "${path}" rev-parse HEAD`);
|
||||||
|
return stdout.trim();
|
||||||
|
} else {
|
||||||
|
return git.commit({
|
||||||
|
fs: fs,
|
||||||
|
dir: path,
|
||||||
|
message,
|
||||||
|
author: await getGitAuthor(),
|
||||||
|
amend: amend,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitCheckout({
|
||||||
|
path,
|
||||||
|
ref,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
ref: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (settings.enableNativeGit) {
|
||||||
|
await execAsync(`git -C "${path}" checkout "${ref.replace(/"/g, '\\"')}"`);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return git.checkout({ fs, dir: path, ref });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitStageToRevert({
|
||||||
|
path,
|
||||||
|
targetOid,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
targetOid: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (settings.enableNativeGit) {
|
||||||
|
// Get the current HEAD commit hash
|
||||||
|
const { stdout: currentHead } = await execAsync(
|
||||||
|
`git -C "${path}" rev-parse HEAD`,
|
||||||
|
);
|
||||||
|
const currentCommit = currentHead.trim();
|
||||||
|
|
||||||
|
// If we're already at the target commit, nothing to do
|
||||||
|
if (currentCommit === targetOid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: refuse to run if the work-tree isn’t clean.
|
||||||
|
const { stdout: wtStatus } = await execAsync(
|
||||||
|
`git -C "${path}" status --porcelain`,
|
||||||
|
);
|
||||||
|
if (wtStatus.trim() !== "") {
|
||||||
|
throw new Error("Cannot revert: working tree has uncommitted changes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the working directory and index to match the target commit state
|
||||||
|
// This effectively undoes all changes since the target commit
|
||||||
|
await execAsync(`git -C "${path}" reset --hard "${targetOid}"`);
|
||||||
|
|
||||||
|
// Reset back to the original HEAD but keep the working directory as it is
|
||||||
|
// This stages all the changes needed to revert to the target state
|
||||||
|
await execAsync(`git -C "${path}" reset --soft "${currentCommit}"`);
|
||||||
|
} else {
|
||||||
|
// Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory
|
||||||
|
const matrix = await git.statusMatrix({
|
||||||
|
fs,
|
||||||
|
dir: path,
|
||||||
|
ref: targetOid,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process each file to revert to the state in previousVersionId
|
||||||
|
for (const [filepath, headStatus, workdirStatus] of matrix) {
|
||||||
|
const fullPath = pathModule.join(path, filepath);
|
||||||
|
|
||||||
|
// If file exists in HEAD (previous version)
|
||||||
|
if (headStatus === 1) {
|
||||||
|
// If file doesn't exist or has changed in working directory, restore it from the target commit
|
||||||
|
if (workdirStatus !== 1) {
|
||||||
|
const { blob } = await git.readBlob({
|
||||||
|
fs,
|
||||||
|
dir: path,
|
||||||
|
oid: targetOid,
|
||||||
|
filepath,
|
||||||
|
});
|
||||||
|
await fsPromises.mkdir(pathModule.dirname(fullPath), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await fsPromises.writeFile(fullPath, Buffer.from(blob));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If file doesn't exist in HEAD but exists in working directory, delete it
|
||||||
|
else if (headStatus === 0 && workdirStatus !== 0) {
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
await fsPromises.unlink(fullPath);
|
||||||
|
await git.remove({
|
||||||
|
fs,
|
||||||
|
dir: path,
|
||||||
|
filepath: filepath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage all changes
|
||||||
|
await git.add({
|
||||||
|
fs,
|
||||||
|
dir: path,
|
||||||
|
filepath: ".",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,8 @@ export const UserSettingsSchema = z.object({
|
|||||||
enableProSmartFilesContextMode: z.boolean().optional(),
|
enableProSmartFilesContextMode: z.boolean().optional(),
|
||||||
selectedTemplateId: z.string().optional(),
|
selectedTemplateId: z.string().optional(),
|
||||||
|
|
||||||
|
enableNativeGit: z.boolean().optional(),
|
||||||
|
|
||||||
////////////////////////////////
|
////////////////////////////////
|
||||||
// E2E TESTING ONLY.
|
// E2E TESTING ONLY.
|
||||||
////////////////////////////////
|
////////////////////////////////
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router";
|
|||||||
import { GitHubIntegration } from "@/components/GitHubIntegration";
|
import { GitHubIntegration } from "@/components/GitHubIntegration";
|
||||||
import { SupabaseIntegration } from "@/components/SupabaseIntegration";
|
import { SupabaseIntegration } from "@/components/SupabaseIntegration";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
@@ -108,6 +109,35 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 mt-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="enable-native-git"
|
||||||
|
checked={!!settings?.enableNativeGit}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateSettings({
|
||||||
|
enableNativeGit: checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="enable-native-git">Enable Native Git</Label>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
(Experimental) Native Git offers faster performance but requires{" "}
|
||||||
|
<a
|
||||||
|
onClick={() => {
|
||||||
|
IpcClient.getInstance().openExternalUrl(
|
||||||
|
"https://git-scm.com/downloads",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
installing Git
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<MaxChatTurnsSelector />
|
<MaxChatTurnsSelector />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user