Skip to content

Commit 37ed086

Browse files
committed
Add setup wizard for new user scenarios
- Add repo state detection (not-a-repo, empty-repo, local-only, remote-no-upstream, ready) - Add setup wizard in settings to guide users through different scenarios - Handle empty remote repositories correctly (create initial commit and push) - Auto-add config directory to .gitignore when ignoreObsidianDir is enabled - Add i18n translations for wizard UI (en/zh)
1 parent d409379 commit 37ed086

File tree

4 files changed

+448
-13
lines changed

4 files changed

+448
-13
lines changed

main.js

Lines changed: 8 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/git.ts

Lines changed: 204 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { execFile } from "child_process";
2+
import { promises as fs } from "fs";
3+
import * as path from "path";
24

35
interface GitRunOptions {
46
cwd: string;
@@ -27,6 +29,63 @@ function runGit({ cwd, gitPath, args }: GitRunOptions): Promise<string> {
2729
});
2830
}
2931

32+
async function ensureGitignore(cwd: string, ignoreDir?: string): Promise<void> {
33+
if (!ignoreDir) return;
34+
35+
const gitignorePath = path.join(cwd, ".gitignore");
36+
const ignorePattern = ignoreDir.endsWith("/") ? ignoreDir : `${ignoreDir}/`;
37+
38+
let content = "";
39+
try {
40+
content = await fs.readFile(gitignorePath, "utf8");
41+
} catch (e) {
42+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
43+
}
44+
45+
const lines = content.split(/\r?\n/);
46+
if (lines.some((line) => line.trim() === ignorePattern)) return;
47+
48+
const eol = content.includes("\r\n") ? "\r\n" : "\n";
49+
const prefix = content.length > 0 && !content.endsWith("\n") ? eol : "";
50+
await fs.writeFile(gitignorePath, `${content}${prefix}${ignorePattern}${eol}`, "utf8");
51+
}
52+
53+
// Repo state detection
54+
export type RepoState =
55+
| "not-a-repo"
56+
| "empty-repo"
57+
| "local-only"
58+
| "remote-no-upstream"
59+
| "ready";
60+
61+
export async function detectRepoState(cwd: string, gitPath: string): Promise<RepoState> {
62+
// Check if it's a git repo
63+
if (!(await isGitRepo(cwd, gitPath))) {
64+
return "not-a-repo";
65+
}
66+
67+
// Check if there are any commits
68+
try {
69+
await runGit({ cwd, gitPath, args: ["rev-parse", "HEAD"] });
70+
} catch {
71+
return "empty-repo";
72+
}
73+
74+
// Check if remote is configured
75+
const remoteUrl = await getRemoteUrl(cwd, gitPath);
76+
if (!remoteUrl) {
77+
return "local-only";
78+
}
79+
80+
// Check if upstream is set
81+
try {
82+
await runGit({ cwd, gitPath, args: ["rev-parse", "--abbrev-ref", "@{u}"] });
83+
return "ready";
84+
} catch {
85+
return "remote-no-upstream";
86+
}
87+
}
88+
3089
export async function getChangedFiles(cwd: string, gitPath: string): Promise<string[]> {
3190
const stdout = await runGit({ cwd, gitPath, args: ["status", "--porcelain=v1", "-z"] });
3291
if (!stdout) return [];
@@ -65,11 +124,6 @@ export async function commitAll(cwd: string, gitPath: string, message: string):
65124
}
66125
}
67126

68-
async function getCurrentBranch(cwd: string, gitPath: string): Promise<string> {
69-
const stdout = await runGit({ cwd, gitPath, args: ["rev-parse", "--abbrev-ref", "HEAD"] });
70-
return stdout.trim();
71-
}
72-
73127
export async function push(cwd: string, gitPath: string): Promise<void> {
74128
const branch = await getCurrentBranch(cwd, gitPath);
75129
await runGit({ cwd, gitPath, args: ["push", "-u", "origin", branch] });
@@ -79,13 +133,32 @@ export interface PullResult {
79133
success: boolean;
80134
hasConflicts: boolean;
81135
message: string;
136+
notReady?: boolean;
82137
}
83138

84139
export async function pull(cwd: string, gitPath: string): Promise<PullResult> {
140+
// Check repo state first
141+
const state = await detectRepoState(cwd, gitPath);
142+
if (state !== "ready") {
143+
return {
144+
success: false,
145+
hasConflicts: false,
146+
message: `Repository not ready: ${state}`,
147+
notReady: true
148+
};
149+
}
150+
85151
try {
86-
const branch = await getCurrentBranch(cwd, gitPath);
87-
const stdout = await runGit({ cwd, gitPath, args: ["pull", "origin", branch] });
88-
return { success: true, hasConflicts: false, message: stdout };
152+
// Try to use upstream first
153+
try {
154+
const stdout = await runGit({ cwd, gitPath, args: ["pull"] });
155+
return { success: true, hasConflicts: false, message: stdout };
156+
} catch {
157+
// Fallback to explicit origin/branch
158+
const branch = await getCurrentBranch(cwd, gitPath);
159+
const stdout = await runGit({ cwd, gitPath, args: ["pull", "origin", branch] });
160+
return { success: true, hasConflicts: false, message: stdout };
161+
}
89162
} catch (e) {
90163
const msg = (e as Error).message;
91164
if (msg.includes("CONFLICT") || msg.includes("Merge conflict")) {
@@ -194,3 +267,126 @@ export async function getFileStatuses(cwd: string, gitPath: string): Promise<Map
194267

195268
return statusMap;
196269
}
270+
271+
// Get remote default branch (main/master)
272+
export async function getRemoteDefaultBranch(cwd: string, gitPath: string): Promise<string | null> {
273+
try {
274+
// Try to get from remote HEAD
275+
const stdout = await runGit({ cwd, gitPath, args: ["remote", "show", "origin"] });
276+
const match = stdout.match(/HEAD branch:\s*(\S+)/);
277+
if (match && match[1] !== "(unknown)") return match[1];
278+
} catch {
279+
// Fallback: try common branch names
280+
}
281+
282+
// Try to find main or master in remote refs
283+
try {
284+
const refs = await runGit({ cwd, gitPath, args: ["ls-remote", "--heads", "origin"] });
285+
if (!refs.trim()) return null; // Empty remote
286+
if (refs.includes("refs/heads/main")) return "main";
287+
if (refs.includes("refs/heads/master")) return "master";
288+
// Return first branch found
289+
const match = refs.match(/refs\/heads\/(\S+)/);
290+
if (match) return match[1];
291+
} catch {
292+
// Ignore
293+
}
294+
295+
return null; // Remote is empty or unreachable
296+
}
297+
298+
// Initialize repo with first commit and push to empty remote
299+
export async function initAndPush(cwd: string, gitPath: string, url: string, branch: string = "main", ignoreDir?: string): Promise<void> {
300+
// Initialize with branch name
301+
await runGit({ cwd, gitPath, args: ["init", "-b", branch] });
302+
303+
// Ensure .gitignore excludes config dir if specified
304+
await ensureGitignore(cwd, ignoreDir);
305+
306+
// Add all files
307+
await runGit({ cwd, gitPath, args: ["add", "-A"] });
308+
309+
// Create initial commit
310+
await runGit({ cwd, gitPath, args: ["commit", "-m", "Initial commit"] });
311+
312+
// Add remote
313+
await runGit({ cwd, gitPath, args: ["remote", "add", "origin", url] });
314+
315+
// Push with upstream
316+
await runGit({ cwd, gitPath, args: ["push", "-u", "origin", branch] });
317+
}
318+
319+
// Connect to existing remote repo (fetch and checkout)
320+
export async function connectToRemote(cwd: string, gitPath: string, url: string, ignoreDir?: string): Promise<{ branch: string }> {
321+
// Initialize if needed
322+
if (!(await isGitRepo(cwd, gitPath))) {
323+
await runGit({ cwd, gitPath, args: ["init", "-b", "main"] });
324+
}
325+
326+
// Add remote
327+
const currentUrl = await getRemoteUrl(cwd, gitPath);
328+
if (!currentUrl) {
329+
await runGit({ cwd, gitPath, args: ["remote", "add", "origin", url] });
330+
} else if (currentUrl !== url) {
331+
await runGit({ cwd, gitPath, args: ["remote", "set-url", "origin", url] });
332+
}
333+
334+
// Fetch remote
335+
await runGit({ cwd, gitPath, args: ["fetch", "origin"] });
336+
337+
// Get remote default branch (null if remote is empty)
338+
const remoteBranch = await getRemoteDefaultBranch(cwd, gitPath);
339+
340+
if (!remoteBranch) {
341+
// Remote is empty - create initial commit and push
342+
await ensureGitignore(cwd, ignoreDir);
343+
await runGit({ cwd, gitPath, args: ["add", "-A"] });
344+
try {
345+
await runGit({ cwd, gitPath, args: ["commit", "-m", "Initial commit"] });
346+
} catch (e) {
347+
// Might fail if no files to commit, that's ok
348+
const msg = (e as Error).message;
349+
if (!msg.includes("nothing to commit")) {
350+
throw e;
351+
}
352+
}
353+
await runGit({ cwd, gitPath, args: ["push", "-u", "origin", "main"] });
354+
return { branch: "main" };
355+
}
356+
357+
// Remote has content - check if we have local commits
358+
let hasLocalCommits = false;
359+
try {
360+
await runGit({ cwd, gitPath, args: ["rev-parse", "HEAD"] });
361+
hasLocalCommits = true;
362+
} catch {
363+
hasLocalCommits = false;
364+
}
365+
366+
if (!hasLocalCommits) {
367+
// No local commits: checkout remote branch directly
368+
await runGit({ cwd, gitPath, args: ["checkout", "-b", remoteBranch, `origin/${remoteBranch}`] });
369+
} else {
370+
// Has local commits: rename branch and set upstream
371+
try {
372+
await runGit({ cwd, gitPath, args: ["branch", `-M`, remoteBranch] });
373+
} catch {
374+
// Branch might already be named correctly
375+
}
376+
await runGit({ cwd, gitPath, args: ["branch", `--set-upstream-to=origin/${remoteBranch}`, remoteBranch] });
377+
}
378+
379+
return { branch: remoteBranch };
380+
}
381+
382+
// Set upstream for current branch
383+
export async function setUpstream(cwd: string, gitPath: string): Promise<void> {
384+
const branch = await getCurrentBranch(cwd, gitPath);
385+
await runGit({ cwd, gitPath, args: ["push", "-u", "origin", branch] });
386+
}
387+
388+
// Get current branch name
389+
async function getCurrentBranch(cwd: string, gitPath: string): Promise<string> {
390+
const stdout = await runGit({ cwd, gitPath, args: ["rev-parse", "--abbrev-ref", "HEAD"] });
391+
return stdout.trim();
392+
}

src/i18n.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,34 @@ type Translations = {
4343
noticeReverted: string;
4444
noticeRevertFailed: (msg: string) => string;
4545

46+
// Setup wizard
47+
sectionSetup: string;
48+
setupNotRepo: string;
49+
setupEmptyRepo: string;
50+
setupLocalOnly: string;
51+
setupNoUpstream: string;
52+
setupReady: string;
53+
54+
wizardConnectRemote: string;
55+
wizardConnectRemoteDesc: string;
56+
wizardConnectButton: string;
57+
wizardInitAndPush: string;
58+
wizardInitAndPushDesc: string;
59+
wizardInitAndPushButton: string;
60+
wizardLocalOnly: string;
61+
wizardLocalOnlyDesc: string;
62+
wizardLocalOnlyButton: string;
63+
wizardSetUpstream: string;
64+
wizardSetUpstreamDesc: string;
65+
wizardSetUpstreamButton: string;
66+
67+
noticeConnected: string;
68+
noticeConnectFailed: (msg: string) => string;
69+
noticeInitPushSuccess: string;
70+
noticeInitPushFailed: (msg: string) => string;
71+
noticeUpstreamSet: string;
72+
noticeUpstreamFailed: (msg: string) => string;
73+
4674
// Repository
4775
repoStatusName: string;
4876
repoNotInitialized: string;
@@ -128,6 +156,33 @@ const en: Translations = {
128156
noticeReverted: "GitAutoCommit: All changes reverted.",
129157
noticeRevertFailed: (msg) => `GitAutoCommit: Revert failed - ${msg}`,
130158

159+
sectionSetup: "Setup",
160+
setupNotRepo: "Not a Git repository",
161+
setupEmptyRepo: "Empty repository (no commits)",
162+
setupLocalOnly: "Local only (no remote)",
163+
setupNoUpstream: "Remote configured (no upstream)",
164+
setupReady: "Ready",
165+
166+
wizardConnectRemote: "Connect to remote repository",
167+
wizardConnectRemoteDesc: "Sync with an existing remote repository.",
168+
wizardConnectButton: "Connect",
169+
wizardInitAndPush: "Create new repository",
170+
wizardInitAndPushDesc: "Initialize and push to an empty remote.",
171+
wizardInitAndPushButton: "Create & Push",
172+
wizardLocalOnly: "Local version control only",
173+
wizardLocalOnlyDesc: "Just track changes locally without remote sync.",
174+
wizardLocalOnlyButton: "Initialize",
175+
wizardSetUpstream: "Set upstream branch",
176+
wizardSetUpstreamDesc: "Push current branch and set upstream tracking.",
177+
wizardSetUpstreamButton: "Set Upstream",
178+
179+
noticeConnected: "GitAutoCommit: Connected to remote repository.",
180+
noticeConnectFailed: (msg) => `GitAutoCommit: Connect failed - ${msg}`,
181+
noticeInitPushSuccess: "GitAutoCommit: Repository created and pushed.",
182+
noticeInitPushFailed: (msg) => `GitAutoCommit: Init/push failed - ${msg}`,
183+
noticeUpstreamSet: "GitAutoCommit: Upstream branch set.",
184+
noticeUpstreamFailed: (msg) => `GitAutoCommit: Set upstream failed - ${msg}`,
185+
131186
repoStatusName: "Repository status",
132187
repoNotInitialized: "Not a git repository",
133188
repoInitialized: "Git repository initialized",
@@ -210,6 +265,33 @@ const zhCN: Translations = {
210265
noticeReverted: "GitAutoCommit: 已还原所有修改。",
211266
noticeRevertFailed: (msg) => `GitAutoCommit: 还原失败 - ${msg}`,
212267

268+
sectionSetup: "初始设置",
269+
setupNotRepo: "尚未初始化为 Git 仓库",
270+
setupEmptyRepo: "空仓库(无提交)",
271+
setupLocalOnly: "仅本地(无远程)",
272+
setupNoUpstream: "已配置远程(无上游分支)",
273+
setupReady: "就绪",
274+
275+
wizardConnectRemote: "连接远程仓库",
276+
wizardConnectRemoteDesc: "同步到已有的远程仓库。",
277+
wizardConnectButton: "连接",
278+
wizardInitAndPush: "创建新仓库",
279+
wizardInitAndPushDesc: "初始化并推送到空的远程仓库。",
280+
wizardInitAndPushButton: "创建并推送",
281+
wizardLocalOnly: "仅本地版本控制",
282+
wizardLocalOnlyDesc: "仅在本地跟踪变更,不同步远程。",
283+
wizardLocalOnlyButton: "初始化",
284+
wizardSetUpstream: "设置上游分支",
285+
wizardSetUpstreamDesc: "推送当前分支并设置上游跟踪。",
286+
wizardSetUpstreamButton: "设置上游",
287+
288+
noticeConnected: "GitAutoCommit: 已连接到远程仓库。",
289+
noticeConnectFailed: (msg) => `GitAutoCommit: 连接失败 - ${msg}`,
290+
noticeInitPushSuccess: "GitAutoCommit: 仓库已创建并推送。",
291+
noticeInitPushFailed: (msg) => `GitAutoCommit: 初始化/推送失败 - ${msg}`,
292+
noticeUpstreamSet: "GitAutoCommit: 已设置上游分支。",
293+
noticeUpstreamFailed: (msg) => `GitAutoCommit: 设置上游失败 - ${msg}`,
294+
213295
repoStatusName: "仓库状态",
214296
repoNotInitialized: "尚未初始化为 Git 仓库",
215297
repoInitialized: "Git 仓库已初始化",

0 commit comments

Comments
 (0)