feat(git-manager): multi-repo workspace support with repo selector
- Add GET /api/git/workspace-repos endpoint returning sub-repo list - Add resolveGitDir() helper: resolves repoPath query param to sub-repo dir - Update all 34 git endpoints to use resolveGitDir for workspace targeting - Add repoPath param to 30+ frontend git API functions - GitManagerModal: auto-detect workspace repos on mount, show repo selector dropdown at top of sidebar, pass selected repo to all git API calls - Auto-select first repo when workspace mode is detected - Repo change triggers data refetch via gitRepoPath dependency
This commit is contained in:
@@ -2502,8 +2502,8 @@ export interface GitRemote {
|
||||
}
|
||||
|
||||
/** Fetch GitHub remotes from the current git repository */
|
||||
export function fetchGitRemotes(projectId?: string): Promise<GitRemote[]> {
|
||||
return api<GitRemote[]>(withProjectId("/git/remotes", projectId));
|
||||
export function fetchGitRemotes(projectId?: string, repoPath?: string): Promise<GitRemote[]> {
|
||||
return api<GitRemote[]>(withRepoPath(withProjectId("/git/remotes", projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Detailed git remote info with fetch and push URLs */
|
||||
@@ -2514,36 +2514,36 @@ export interface GitRemoteDetailed {
|
||||
}
|
||||
|
||||
/** Fetch all git remotes with their fetch and push URLs */
|
||||
export function fetchGitRemotesDetailed(projectId?: string): Promise<GitRemoteDetailed[]> {
|
||||
return api<GitRemoteDetailed[]>(withProjectId("/git/remotes/detailed", projectId));
|
||||
export function fetchGitRemotesDetailed(projectId?: string, repoPath?: string): Promise<GitRemoteDetailed[]> {
|
||||
return api<GitRemoteDetailed[]>(withRepoPath(withProjectId("/git/remotes/detailed", projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Add a new git remote */
|
||||
export function addGitRemote(name: string, url: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/git/remotes", projectId), {
|
||||
export function addGitRemote(name: string, url: string, projectId?: string, repoPath?: string): Promise<void> {
|
||||
return api<void>(withRepoPath(withProjectId("/git/remotes", projectId), repoPath), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, url }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a git remote */
|
||||
export function removeGitRemote(name: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), {
|
||||
export function removeGitRemote(name: string, projectId?: string, repoPath?: string): Promise<void> {
|
||||
return api<void>(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), repoPath), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Rename a git remote */
|
||||
export function renameGitRemote(name: string, newName: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), {
|
||||
export function renameGitRemote(name: string, newName: string, projectId?: string, repoPath?: string): Promise<void> {
|
||||
return api<void>(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), repoPath), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ newName }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update the URL for a git remote */
|
||||
export function updateGitRemoteUrl(name: string, url: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/git/remotes/${encodeURIComponent(name)}/url`, projectId), {
|
||||
export function updateGitRemoteUrl(name: string, url: string, projectId?: string, repoPath?: string): Promise<void> {
|
||||
return api<void>(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(name)}/url`, projectId), repoPath), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
@@ -3041,104 +3041,105 @@ export interface GitPushResult {
|
||||
* resolution, ahead/behind vs both local and origin integration tip, dirty
|
||||
* breakdown, stash count, index-stale detection, and recent merge-advance
|
||||
* audit events for the project-root worktree. */
|
||||
export function fetchGitStatus(projectId?: string, opts?: { extended?: boolean }): Promise<GitStatus> {
|
||||
const base = withProjectId("/git/status", projectId);
|
||||
export function fetchGitStatus(projectId?: string, opts?: { extended?: boolean }, repoPath?: string): Promise<GitStatus> {
|
||||
const base = withRepoPath(withProjectId("/git/status", projectId), repoPath);
|
||||
if (!opts?.extended) return api<GitStatus>(base);
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return api<GitStatus>(`${base}${sep}extended=1`);
|
||||
}
|
||||
|
||||
/** Fetch recent commits */
|
||||
export function fetchGitCommits(limit?: number, projectId?: string): Promise<GitCommit[]> {
|
||||
export function fetchGitCommits(limit?: number, projectId?: string, repoPath?: string): Promise<GitCommit[]> {
|
||||
const query = limit ? `?limit=${limit}` : "";
|
||||
return api<GitCommit[]>(withProjectId(`/git/commits${query}`, projectId));
|
||||
return api<GitCommit[]>(withRepoPath(withProjectId(`/git/commits${query}`, projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Fetch diff for a specific commit */
|
||||
export function fetchCommitDiff(hash: string, projectId?: string): Promise<{ stat: string; patch: string }> {
|
||||
return api<{ stat: string; patch: string }>(withProjectId(`/git/commits/${hash}/diff`, projectId));
|
||||
export function fetchCommitDiff(hash: string, projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> {
|
||||
return api<{ stat: string; patch: string }>(withRepoPath(withProjectId(`/git/commits/${hash}/diff`, projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Fetch local commits ahead of the upstream tracking branch (commits to push) */
|
||||
export function fetchAheadCommits(projectId?: string): Promise<GitCommit[]> {
|
||||
return api<GitCommit[]>(withProjectId("/git/commits/ahead", projectId));
|
||||
export function fetchAheadCommits(projectId?: string, repoPath?: string): Promise<GitCommit[]> {
|
||||
return api<GitCommit[]>(withRepoPath(withProjectId("/git/commits/ahead", projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Fetch recent commits for a specific remote */
|
||||
export function fetchRemoteCommits(remote: string, ref?: string, limit?: number, projectId?: string): Promise<GitCommit[]> {
|
||||
export function fetchRemoteCommits(remote: string, ref?: string, limit?: number, projectId?: string, repoPath?: string): Promise<GitCommit[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (ref) params.set("ref", ref);
|
||||
if (limit) params.set("limit", String(limit));
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<GitCommit[]>(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId));
|
||||
return api<GitCommit[]>(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Fetch all local branches */
|
||||
export function fetchGitBranches(projectId?: string): Promise<GitBranch[]> {
|
||||
return api<GitBranch[]>(withProjectId("/git/branches", projectId));
|
||||
export function fetchGitBranches(projectId?: string, repoPath?: string): Promise<GitBranch[]> {
|
||||
return api<GitBranch[]>(withRepoPath(withProjectId("/git/branches", projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Fetch recent commits for a specific branch */
|
||||
export function fetchBranchCommits(branchName: string, limit?: number, projectId?: string): Promise<GitCommit[]> {
|
||||
export function fetchBranchCommits(branchName: string, limit?: number, projectId?: string, repoPath?: string): Promise<GitCommit[]> {
|
||||
const query = limit ? `?limit=${limit}` : "";
|
||||
return api<GitCommit[]>(withProjectId(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`, projectId));
|
||||
return api<GitCommit[]>(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`, projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Fetch all worktrees */
|
||||
export function fetchGitWorktrees(projectId?: string): Promise<GitWorktree[]> {
|
||||
return api<GitWorktree[]>(withProjectId("/git/worktrees", projectId));
|
||||
export function fetchGitWorktrees(projectId?: string, repoPath?: string): Promise<GitWorktree[]> {
|
||||
return api<GitWorktree[]>(withRepoPath(withProjectId("/git/worktrees", projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Create a new branch */
|
||||
export function createBranch(name: string, base?: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/git/branches", projectId), {
|
||||
export function createBranch(name: string, base?: string, projectId?: string, repoPath?: string): Promise<void> {
|
||||
return api<void>(withRepoPath(withProjectId("/git/branches", projectId), repoPath), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, base }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Checkout an existing branch */
|
||||
export function checkoutBranch(name: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/git/branches/${encodeURIComponent(name)}/checkout`, projectId), {
|
||||
export function checkoutBranch(name: string, projectId?: string, repoPath?: string): Promise<void> {
|
||||
return api<void>(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(name)}/checkout`, projectId), repoPath), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a branch */
|
||||
export function deleteBranch(name: string, force?: boolean, projectId?: string): Promise<void> {
|
||||
export function deleteBranch(name: string, force?: boolean, projectId?: string, repoPath?: string): Promise<void> {
|
||||
const query = force ? "?force=true" : "";
|
||||
return api<void>(withProjectId(`/git/branches/${encodeURIComponent(name)}${query}`, projectId), {
|
||||
return api<void>(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(name)}${query}`, projectId), repoPath), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch from remote */
|
||||
export function fetchRemote(remote?: string, projectId?: string): Promise<GitFetchResult> {
|
||||
return api<GitFetchResult>(withProjectId("/git/fetch", projectId), {
|
||||
export function fetchRemote(remote?: string, projectId?: string, repoPath?: string): Promise<GitFetchResult> {
|
||||
return api<GitFetchResult>(withRepoPath(withProjectId("/git/fetch", projectId), repoPath), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ remote }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Pull current branch */
|
||||
export function pullBranch(options?: { rebase?: boolean }, projectId?: string): Promise<GitPullResult>;
|
||||
export function pullBranch(projectId?: string): Promise<GitPullResult>;
|
||||
export function pullBranch(options?: { rebase?: boolean }, projectId?: string, repoPath?: string): Promise<GitPullResult>;
|
||||
export function pullBranch(projectId?: string, repoPath?: string): Promise<GitPullResult>;
|
||||
export function pullBranch(
|
||||
optionsOrProjectId?: { rebase?: boolean } | string,
|
||||
projectId?: string,
|
||||
repoPath?: string,
|
||||
): Promise<GitPullResult> {
|
||||
const options = typeof optionsOrProjectId === "string" ? undefined : optionsOrProjectId;
|
||||
const resolvedProjectId = typeof optionsOrProjectId === "string" ? optionsOrProjectId : projectId;
|
||||
|
||||
return api<GitPullResult>(withProjectId("/git/pull", resolvedProjectId), {
|
||||
return api<GitPullResult>(withRepoPath(withProjectId("/git/pull", resolvedProjectId), repoPath), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rebase: options?.rebase ?? false }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Push current branch */
|
||||
export function pushBranch(projectId?: string): Promise<GitPushResult> {
|
||||
return api<GitPushResult>(withProjectId("/git/push", projectId), {
|
||||
export function pushBranch(projectId?: string, repoPath?: string): Promise<GitPushResult> {
|
||||
return api<GitPushResult>(withRepoPath(withProjectId("/git/push", projectId), repoPath), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -3160,83 +3161,83 @@ export interface GitFileChange {
|
||||
}
|
||||
|
||||
/** Fetch stash list */
|
||||
export function fetchGitStashList(projectId?: string): Promise<GitStash[]> {
|
||||
return api<GitStash[]>(withProjectId("/git/stashes", projectId));
|
||||
export function fetchGitStashList(projectId?: string, repoPath?: string): Promise<GitStash[]> {
|
||||
return api<GitStash[]>(withRepoPath(withProjectId("/git/stashes", projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Create a new stash */
|
||||
export function createStash(message?: string, projectId?: string): Promise<{ message: string }> {
|
||||
return api<{ message: string }>(withProjectId("/git/stashes", projectId), {
|
||||
export function createStash(message?: string, projectId?: string, repoPath?: string): Promise<{ message: string }> {
|
||||
return api<{ message: string }>(withRepoPath(withProjectId("/git/stashes", projectId), repoPath), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply a stash entry */
|
||||
export function applyStash(index: number, drop?: boolean, projectId?: string): Promise<{ message: string }> {
|
||||
return api<{ message: string }>(withProjectId(`/git/stashes/${index}/apply`, projectId), {
|
||||
export function applyStash(index: number, drop?: boolean, projectId?: string, repoPath?: string): Promise<{ message: string }> {
|
||||
return api<{ message: string }>(withRepoPath(withProjectId(`/git/stashes/${index}/apply`, projectId), repoPath), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ drop }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a stash entry */
|
||||
export function dropStash(index: number, projectId?: string): Promise<{ message: string }> {
|
||||
return api<{ message: string }>(withProjectId(`/git/stashes/${index}`, projectId), {
|
||||
export function dropStash(index: number, projectId?: string, repoPath?: string): Promise<{ message: string }> {
|
||||
return api<{ message: string }>(withRepoPath(withProjectId(`/git/stashes/${index}`, projectId), repoPath), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch stash diff (stat + patch) */
|
||||
export function fetchStashDiff(index: number, projectId?: string): Promise<{ stat: string; patch: string }> {
|
||||
return api<{ stat: string; patch: string }>(withProjectId(`/git/stashes/${index}/diff`, projectId));
|
||||
export function fetchStashDiff(index: number, projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> {
|
||||
return api<{ stat: string; patch: string }>(withRepoPath(withProjectId(`/git/stashes/${index}/diff`, projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Fetch unstaged diff (working directory changes) */
|
||||
export function fetchUnstagedDiff(projectId?: string): Promise<{ stat: string; patch: string }> {
|
||||
return api<{ stat: string; patch: string }>(withProjectId("/git/diff", projectId));
|
||||
export function fetchUnstagedDiff(projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> {
|
||||
return api<{ stat: string; patch: string }>(withRepoPath(withProjectId("/git/diff", projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Fetch diff for a specific file in staged or unstaged mode */
|
||||
export function fetchGitFileDiff(path: string, staged: boolean, projectId?: string): Promise<{ stat: string; patch: string }> {
|
||||
export function fetchGitFileDiff(path: string, staged: boolean, projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("path", path);
|
||||
params.set("staged", String(staged));
|
||||
return api<{ stat: string; patch: string }>(withProjectId(`/git/diff/file?${params.toString()}`, projectId));
|
||||
return api<{ stat: string; patch: string }>(withRepoPath(withProjectId(`/git/diff/file?${params.toString()}`, projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Fetch file changes (staged and unstaged) */
|
||||
export function fetchFileChanges(projectId?: string): Promise<GitFileChange[]> {
|
||||
return api<GitFileChange[]>(withProjectId("/git/changes", projectId));
|
||||
export function fetchFileChanges(projectId?: string, repoPath?: string): Promise<GitFileChange[]> {
|
||||
return api<GitFileChange[]>(withRepoPath(withProjectId("/git/changes", projectId), repoPath));
|
||||
}
|
||||
|
||||
/** Stage specific files */
|
||||
export function stageFiles(files: string[], projectId?: string): Promise<{ staged: string[] }> {
|
||||
return api<{ staged: string[] }>(withProjectId("/git/stage", projectId), {
|
||||
export function stageFiles(files: string[], projectId?: string, repoPath?: string): Promise<{ staged: string[] }> {
|
||||
return api<{ staged: string[] }>(withRepoPath(withProjectId("/git/stage", projectId), repoPath), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ files }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Unstage specific files */
|
||||
export function unstageFiles(files: string[], projectId?: string): Promise<{ unstaged: string[] }> {
|
||||
return api<{ unstaged: string[] }>(withProjectId("/git/unstage", projectId), {
|
||||
export function unstageFiles(files: string[], projectId?: string, repoPath?: string): Promise<{ unstaged: string[] }> {
|
||||
return api<{ unstaged: string[] }>(withRepoPath(withProjectId("/git/unstage", projectId), repoPath), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ files }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a commit */
|
||||
export function createCommit(message: string, projectId?: string): Promise<{ hash: string; message: string }> {
|
||||
return api<{ hash: string; message: string }>(withProjectId("/git/commit", projectId), {
|
||||
export function createCommit(message: string, projectId?: string, repoPath?: string): Promise<{ hash: string; message: string }> {
|
||||
return api<{ hash: string; message: string }>(withRepoPath(withProjectId("/git/commit", projectId), repoPath), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Discard changes in working directory for specific files */
|
||||
export function discardChanges(files: string[], projectId?: string): Promise<{ discarded: string[] }> {
|
||||
return api<{ discarded: string[] }>(withProjectId("/git/discard", projectId), {
|
||||
export function discardChanges(files: string[], projectId?: string, repoPath?: string): Promise<{ discarded: string[] }> {
|
||||
return api<{ discarded: string[] }>(withRepoPath(withProjectId("/git/discard", projectId), repoPath), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ files }),
|
||||
});
|
||||
@@ -5881,6 +5882,18 @@ function withProjectId(path: string, projectId?: string): string {
|
||||
return `${path}${separator}projectId=${encodeURIComponent(projectId)}`;
|
||||
}
|
||||
|
||||
/** Append repoPath query param for workspace-mode sub-repo targeting */
|
||||
function withRepoPath(path: string, repoPath?: string): string {
|
||||
if (!repoPath) return path;
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
return `${path}${separator}repoPath=${encodeURIComponent(repoPath)}`;
|
||||
}
|
||||
|
||||
/** Fetch workspace sub-repos for a project */
|
||||
export function fetchWorkspaceRepos(projectId?: string): Promise<{ repos: string[] }> {
|
||||
return api<{ repos: string[] }>(withProjectId("/git/workspace-repos", projectId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a path to route through the node proxy when viewing a remote node.
|
||||
* When nodeId is provided and differs from localNodeId (i.e., it's a remote node),
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
fetchAheadCommits,
|
||||
fetchRemoteCommits,
|
||||
fetchBranchCommits,
|
||||
fetchWorkspaceRepos,
|
||||
} from "../api";
|
||||
import { StashRecoveryView } from "./StashRecoveryView";
|
||||
import {
|
||||
@@ -256,6 +257,17 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
|
||||
const [rootDir, setRootDir] = useState<string | null>(null);
|
||||
|
||||
// ── Workspace repo selector state
|
||||
/*
|
||||
FNXC:Workspace 2026-06-24-21:00:
|
||||
In workspace mode (multi-repo), the git manager shows a repo selector so the
|
||||
user can pick which sub-repo to inspect. selectedRepo is the relative path
|
||||
(e.g. "openvide"); gitRepoPath is passed as repoPath to all git API calls.
|
||||
*/
|
||||
const [workspaceRepos, setWorkspaceRepos] = useState<string[]>([]);
|
||||
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
|
||||
const gitRepoPath = selectedRepo ?? undefined;
|
||||
|
||||
// ── Changes state
|
||||
const [fileChanges, setFileChanges] = useState<GitFileChange[]>([]);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
@@ -313,12 +325,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
try {
|
||||
switch (activeSection) {
|
||||
case "status": {
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
|
||||
setStatus(statusData);
|
||||
break;
|
||||
}
|
||||
case "changes": {
|
||||
const [statusData, changes] = await Promise.all([fetchGitStatus(projectId, { extended: true }), fetchFileChanges(projectId)]);
|
||||
const [statusData, changes] = await Promise.all([fetchGitStatus(projectId, { extended: true }, gitRepoPath), fetchFileChanges(projectId, gitRepoPath)]);
|
||||
setStatus(statusData);
|
||||
setFileChanges(changes);
|
||||
setSelectedFiles(new Set());
|
||||
@@ -328,23 +340,23 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
break;
|
||||
}
|
||||
case "commits": {
|
||||
const commitsData = await fetchGitCommits(commitsLimit, projectId);
|
||||
const commitsData = await fetchGitCommits(commitsLimit, projectId, gitRepoPath);
|
||||
setCommits(commitsData);
|
||||
break;
|
||||
}
|
||||
case "branches": {
|
||||
const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(projectId), fetchGitStatus(projectId, { extended: true })]);
|
||||
const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]);
|
||||
setBranches(branchesData);
|
||||
setStatus(statusForBranch);
|
||||
break;
|
||||
}
|
||||
case "worktrees": {
|
||||
const worktreesData = await fetchGitWorktrees(projectId);
|
||||
const worktreesData = await fetchGitWorktrees(projectId, gitRepoPath);
|
||||
setWorktrees(worktreesData);
|
||||
break;
|
||||
}
|
||||
case "stashes": {
|
||||
const stashesData = await fetchGitStashList(projectId);
|
||||
const stashesData = await fetchGitStashList(projectId, gitRepoPath);
|
||||
setStashes(stashesData);
|
||||
setExpandedStashIndex(null);
|
||||
setStashDiff(null);
|
||||
@@ -357,7 +369,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
break;
|
||||
}
|
||||
case "remotes": {
|
||||
const remoteStatus = await fetchGitStatus(projectId, { extended: true });
|
||||
const remoteStatus = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
|
||||
setStatus(remoteStatus);
|
||||
break;
|
||||
}
|
||||
@@ -368,7 +380,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeSection, isOpen, commitsLimit, addToast, projectId]);
|
||||
}, [activeSection, isOpen, commitsLimit, addToast, projectId, gitRepoPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -405,9 +417,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
|
||||
const handleStageFiles = useCallback(async (files: string[]) => {
|
||||
try {
|
||||
await stageFiles(files, projectId);
|
||||
await stageFiles(files, projectId, gitRepoPath);
|
||||
addToast(t("git.stagedFiles", "Staged {{count}} file(s)", { count: files.length }), "success");
|
||||
const changes = await fetchFileChanges(projectId);
|
||||
const changes = await fetchFileChanges(projectId, gitRepoPath);
|
||||
setFileChanges(changes);
|
||||
setSelectedFiles(new Set());
|
||||
setSelectedDiffTarget(null);
|
||||
@@ -420,9 +432,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
|
||||
const handleUnstageFiles = useCallback(async (files: string[]) => {
|
||||
try {
|
||||
await unstageFiles(files, projectId);
|
||||
await unstageFiles(files, projectId, gitRepoPath);
|
||||
addToast(t("git.unstagedFiles", "Unstaged {{count}} file(s)", { count: files.length }), "success");
|
||||
const changes = await fetchFileChanges(projectId);
|
||||
const changes = await fetchFileChanges(projectId, gitRepoPath);
|
||||
setFileChanges(changes);
|
||||
setSelectedFiles(new Set());
|
||||
setSelectedDiffTarget(null);
|
||||
@@ -441,9 +453,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
});
|
||||
if (!shouldDiscard) return;
|
||||
try {
|
||||
await discardChanges(files, projectId);
|
||||
await discardChanges(files, projectId, gitRepoPath);
|
||||
addToast(t("git.discardedFiles", "Discarded changes to {{count}} file(s)", { count: files.length }), "success");
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]);
|
||||
setFileChanges(changes);
|
||||
setStatus(statusData);
|
||||
setSelectedFiles(new Set());
|
||||
@@ -460,11 +472,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
if (!commitMessage.trim()) return;
|
||||
setCommitting(true);
|
||||
try {
|
||||
const result = await createCommit(commitMessage.trim(), projectId);
|
||||
const result = await createCommit(commitMessage.trim(), projectId, gitRepoPath);
|
||||
addToast(t("git.committedHash", "Committed: {{hash}}", { hash: result.hash }), "success");
|
||||
setCommitMessage("");
|
||||
// Refresh changes and status
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]);
|
||||
setFileChanges(changes);
|
||||
setStatus(statusData);
|
||||
setSelectedDiffTarget(null);
|
||||
@@ -483,12 +495,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
try {
|
||||
const unstaged = fileChanges.filter((f) => !f.staged).map((f) => f.file);
|
||||
if (unstaged.length > 0) {
|
||||
await stageFiles(unstaged, projectId);
|
||||
await stageFiles(unstaged, projectId, gitRepoPath);
|
||||
}
|
||||
const result = await createCommit(commitMessage.trim(), projectId);
|
||||
const result = await createCommit(commitMessage.trim(), projectId, gitRepoPath);
|
||||
addToast(t("git.committedHash", "Committed: {{hash}}", { hash: result.hash }), "success");
|
||||
setCommitMessage("");
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]);
|
||||
setFileChanges(changes);
|
||||
setStatus(statusData);
|
||||
setSelectedDiffTarget(null);
|
||||
@@ -509,7 +521,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
changeDiffRequestIdRef.current = requestId;
|
||||
|
||||
try {
|
||||
const diff = await fetchGitFileDiff(file, staged, projectId);
|
||||
const diff = await fetchGitFileDiff(file, staged, projectId, gitRepoPath);
|
||||
if (changeDiffRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
@@ -552,7 +564,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
setSelectedCommit(hash);
|
||||
setLoadingDiff(true);
|
||||
try {
|
||||
const diff = await fetchCommitDiff(hash, projectId);
|
||||
const diff = await fetchCommitDiff(hash, projectId, gitRepoPath);
|
||||
setCommitDiff(diff);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("git.failedToLoadDiff", "Failed to load diff"), "error");
|
||||
@@ -584,11 +596,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
if (!newBranchName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await createBranch(newBranchName.trim(), branchBase.trim() || undefined, projectId);
|
||||
await createBranch(newBranchName.trim(), branchBase.trim() || undefined, projectId, gitRepoPath);
|
||||
addToast(t("git.createdBranch", "Created branch {{name}}", { name: newBranchName }), "success");
|
||||
setNewBranchName("");
|
||||
setBranchBase("");
|
||||
const branchesData = await fetchGitBranches(projectId);
|
||||
const branchesData = await fetchGitBranches(projectId, gitRepoPath);
|
||||
setBranches(branchesData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("git.failedToCreateBranch", "Failed to create branch"), "error");
|
||||
@@ -600,9 +612,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const handleCheckoutBranch = useCallback(async (name: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await checkoutBranch(name, projectId);
|
||||
await checkoutBranch(name, projectId, gitRepoPath);
|
||||
addToast(t("git.switchedToBranch", "Switched to {{name}}", { name }), "success");
|
||||
const [statusData, branchesData] = await Promise.all([fetchGitStatus(projectId, { extended: true }), fetchGitBranches(projectId)]);
|
||||
const [statusData, branchesData] = await Promise.all([fetchGitStatus(projectId, { extended: true }, gitRepoPath), fetchGitBranches(projectId, gitRepoPath)]);
|
||||
setStatus(statusData);
|
||||
setBranches(branchesData);
|
||||
} catch (err) {
|
||||
@@ -621,9 +633,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
if (!shouldDelete) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteBranch(name, undefined, projectId);
|
||||
await deleteBranch(name, undefined, projectId, gitRepoPath);
|
||||
addToast(t("git.deletedBranch", "Deleted branch {{name}}", { name }), "success");
|
||||
const branchesData = await fetchGitBranches(projectId);
|
||||
const branchesData = await fetchGitBranches(projectId, gitRepoPath);
|
||||
setBranches(branchesData);
|
||||
} catch (err) {
|
||||
if (getErrorMessage(err).includes("not fully merged")) {
|
||||
@@ -634,9 +646,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
});
|
||||
if (shouldForceDelete) {
|
||||
try {
|
||||
await deleteBranch(name, true, projectId);
|
||||
await deleteBranch(name, true, projectId, gitRepoPath);
|
||||
addToast(t("git.forceDeletedBranch", "Force deleted branch {{name}}", { name }), "success");
|
||||
const branchesData = await fetchGitBranches(projectId);
|
||||
const branchesData = await fetchGitBranches(projectId, gitRepoPath);
|
||||
setBranches(branchesData);
|
||||
} catch (forceErr) {
|
||||
addToast(getErrorMessage(forceErr) || t("git.failedToDeleteBranch", "Failed to delete branch"), "error");
|
||||
@@ -674,7 +686,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
setBranchCommitDiff(null);
|
||||
setLoadingBranchCommits(true);
|
||||
try {
|
||||
const data = await fetchBranchCommits(name, 10, projectId);
|
||||
const data = await fetchBranchCommits(name, 10, projectId, gitRepoPath);
|
||||
setBranchCommits(data);
|
||||
} catch {
|
||||
setBranchCommits([]);
|
||||
@@ -694,7 +706,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
setBranchCommitDiff(null);
|
||||
setLoadingBranchCommitDiff(true);
|
||||
try {
|
||||
const diff = await fetchCommitDiff(hash, projectId);
|
||||
const diff = await fetchCommitDiff(hash, projectId, gitRepoPath);
|
||||
setBranchCommitDiff(diff);
|
||||
} catch {
|
||||
setBranchCommitDiff(null);
|
||||
@@ -726,10 +738,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
setStashLoading("create");
|
||||
resetStashDiffState();
|
||||
try {
|
||||
await createStash(stashMessage.trim() || undefined, projectId);
|
||||
await createStash(stashMessage.trim() || undefined, projectId, gitRepoPath);
|
||||
addToast(t("git.changesStashed", "Changes stashed"), "success");
|
||||
setStashMessage("");
|
||||
const stashesData = await fetchGitStashList(projectId);
|
||||
const stashesData = await fetchGitStashList(projectId, gitRepoPath);
|
||||
setStashes(stashesData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("git.failedToStashChanges", "Failed to stash changes"), "error");
|
||||
@@ -742,9 +754,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
setStashLoading(`apply-${index}`);
|
||||
resetStashDiffState();
|
||||
try {
|
||||
await applyStash(index, drop, projectId);
|
||||
await applyStash(index, drop, projectId, gitRepoPath);
|
||||
addToast(drop ? t("git.stashPopped", "Stash popped") : t("git.stashApplied", "Stash applied"), "success");
|
||||
const stashesData = await fetchGitStashList(projectId);
|
||||
const stashesData = await fetchGitStashList(projectId, gitRepoPath);
|
||||
setStashes(stashesData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("git.failedToApplyStash", "Failed to apply stash"), "error");
|
||||
@@ -763,9 +775,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
setStashLoading(`drop-${index}`);
|
||||
resetStashDiffState();
|
||||
try {
|
||||
await dropStash(index, projectId);
|
||||
await dropStash(index, projectId, gitRepoPath);
|
||||
addToast(t("git.stashDropped", "Stash dropped"), "success");
|
||||
const stashesData = await fetchGitStashList(projectId);
|
||||
const stashesData = await fetchGitStashList(projectId, gitRepoPath);
|
||||
setStashes(stashesData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("git.failedToDropStash", "Failed to drop stash"), "error");
|
||||
@@ -787,7 +799,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
setStashDiffError(null);
|
||||
setLoadingStashDiff(true);
|
||||
try {
|
||||
const diff = await fetchStashDiff(index, projectId);
|
||||
const diff = await fetchStashDiff(index, projectId, gitRepoPath);
|
||||
if (stashDiffRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
@@ -810,10 +822,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const handleFetch = useCallback(async () => {
|
||||
setRemoteLoading("fetch");
|
||||
try {
|
||||
const result = await fetchRemote(undefined, projectId);
|
||||
const result = await fetchRemote(undefined, projectId, gitRepoPath);
|
||||
setLastRemoteResult(result);
|
||||
addToast(result.message || t("git.fetchCompleted", "Fetch completed"), result.fetched ? "success" : "info");
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
|
||||
setStatus(statusData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("git.fetchFailed", "Fetch failed"), "error");
|
||||
@@ -825,7 +837,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const handlePull = useCallback(async (options?: { rebase?: boolean }) => {
|
||||
setRemoteLoading("pull");
|
||||
try {
|
||||
const result = await pullBranch(options, projectId);
|
||||
const result = await pullBranch(options, projectId, gitRepoPath);
|
||||
setLastRemoteResult(result);
|
||||
if (result.conflict) {
|
||||
addToast(t("git.mergeConflictDetected", "Merge conflict detected. Resolve manually."), "error");
|
||||
@@ -833,7 +845,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const fallbackMessage = options?.rebase ? t("git.pullRebaseCompleted", "Pull --rebase completed") : t("git.pullCompleted", "Pull completed");
|
||||
addToast(result.message || fallbackMessage, "success");
|
||||
}
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
|
||||
setStatus(statusData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("git.pullFailed", "Pull failed"), "error");
|
||||
@@ -845,10 +857,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const handlePush = useCallback(async () => {
|
||||
setRemoteLoading("push");
|
||||
try {
|
||||
const result = await pushBranch(projectId);
|
||||
const result = await pushBranch(projectId, gitRepoPath);
|
||||
setLastRemoteResult(result);
|
||||
addToast(result.message || t("git.pushCompleted", "Push completed"), "success");
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
|
||||
setStatus(statusData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("git.pushFailed", "Push failed"), "error");
|
||||
@@ -860,17 +872,17 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const handleSyncWithOrigin = useCallback(async () => {
|
||||
setRemoteLoading("sync");
|
||||
try {
|
||||
const pullResult = await pullBranch({ rebase: true }, projectId);
|
||||
const pullResult = await pullBranch({ rebase: true }, projectId, gitRepoPath);
|
||||
setLastRemoteResult(pullResult);
|
||||
if (pullResult.conflict) {
|
||||
addToast(t("git.mergeConflictDetected", "Merge conflict detected. Resolve manually."), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const pushResult = await pushBranch(projectId);
|
||||
const pushResult = await pushBranch(projectId, gitRepoPath);
|
||||
setLastRemoteResult(pushResult);
|
||||
addToast(t("git.syncedWithOrigin", "Synced with origin (pull --rebase + push)"), "success");
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
|
||||
setStatus(statusData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("git.syncWithOriginFailed", "Sync with origin failed"), "error");
|
||||
@@ -885,6 +897,18 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
fetchConfig(projectId).then((cfg) => setRootDir(cfg.rootDir)).catch(() => setRootDir(null));
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch workspace repos on mount to determine if this is a multi-repo project.
|
||||
useEffect(() => {
|
||||
fetchWorkspaceRepos(projectId)
|
||||
.then((result) => {
|
||||
setWorkspaceRepos(result.repos);
|
||||
if (result.repos.length > 0 && !selectedRepo) {
|
||||
setSelectedRepo(result.repos[0]!);
|
||||
}
|
||||
})
|
||||
.catch(() => setWorkspaceRepos([]));
|
||||
}, [projectId]); // intentionally omit selectedRepo to avoid resetting on repo switch
|
||||
|
||||
const handleSyncIntegrationTip = useCallback(async () => {
|
||||
if (!status?.integrationBranch || status.isOnIntegrationBranch === false) return;
|
||||
const worktreePath = rootDir;
|
||||
@@ -909,7 +933,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
}),
|
||||
});
|
||||
addToast(t("git.syncedWorktreeToIntegrationTip", "Synced worktree to local integration tip"), "success");
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath);
|
||||
setStatus(statusData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("git.syncFailed", "Sync failed"), "error");
|
||||
@@ -932,6 +956,29 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
<>
|
||||
{/* Sidebar Navigation */}
|
||||
<nav className="gm-sidebar" role="tablist" aria-label={t("git.sidebarAriaLabel", "Git Manager Sections")}>
|
||||
{/*
|
||||
FNXC:Workspace 2026-06-24-21:00:
|
||||
Repo selector for workspace-mode (multi-repo) projects. Placed at the top
|
||||
of the sidebar so it's visible in both modal and embedded presentations.
|
||||
*/}
|
||||
{workspaceRepos.length > 0 && (
|
||||
<div className="gm-repo-selector-wrap">
|
||||
<FolderGit2 size={14} />
|
||||
<select
|
||||
className="gm-repo-selector"
|
||||
value={selectedRepo ?? ""}
|
||||
onChange={(e) => {
|
||||
setSelectedRepo(e.target.value || null);
|
||||
}}
|
||||
title={t("git.selectRepo", "Select repository")}
|
||||
aria-label={t("git.selectRepo", "Select repository")}
|
||||
>
|
||||
{workspaceRepos.map((repo) => (
|
||||
<option key={repo} value={repo}>{repo}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{SECTIONS.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const sectionLabel = {
|
||||
|
||||
@@ -2297,6 +2297,27 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Workspace repo selector ── */
|
||||
.gm-repo-selector-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.gm-repo-selector {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gm-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type NextFunction, type Request, type Response } from "express";
|
||||
import { isAbsolute, resolve, relative } from "node:path";
|
||||
import { isAbsolute, resolve, relative, join } from "node:path";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { exec as execCb, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
@@ -2468,6 +2468,39 @@ export async function refreshIssueInBackground(
|
||||
|
||||
export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, getProjectContext, rethrowAsApiError, store } = ctx;
|
||||
|
||||
/*
|
||||
FNXC:Workspace 2026-06-24-21:00:
|
||||
In workspace mode (multi-repo), git operations target a specific sub-repo.
|
||||
The `repoPath` query param selects which sub-repo. When absent, the project
|
||||
root directory is used (existing single-repo behavior).
|
||||
*/
|
||||
function resolveGitDir(req: Request, projectRoot: string): string {
|
||||
const repoPath = req.query.repoPath;
|
||||
if (typeof repoPath === "string" && repoPath.trim()) {
|
||||
return join(projectRoot, repoPath.trim());
|
||||
}
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/git/workspace-repos
|
||||
* Returns the list of sub-repos for a workspace-mode project.
|
||||
* Non-workspace projects return an empty array.
|
||||
*/
|
||||
router.get("/git/workspace-repos", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
const { loadWorkspaceConfig } = await import("@fusion/core");
|
||||
const config = await loadWorkspaceConfig(rootDir);
|
||||
res.json({ repos: config?.repos ?? [] });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
const githubToken = ctx.options?.githubToken ?? process.env.GITHUB_TOKEN;
|
||||
if (typeof (store as Partial<{ on: unknown; off: unknown }>).on === "function" &&
|
||||
typeof (store as Partial<{ off: unknown }>).off === "function") {
|
||||
@@ -2649,7 +2682,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/remotes", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
const remotes = await getGitHubRemotes(rootDir);
|
||||
res.json(remotes);
|
||||
} catch (err: unknown) {
|
||||
@@ -2668,7 +2701,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/remotes/detailed", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -2690,7 +2723,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/remotes", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
const { name, url } = req.body;
|
||||
if (!name || typeof name !== "string") {
|
||||
throw badRequest("name is required");
|
||||
@@ -2732,7 +2765,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.delete("/git/remotes/:name", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -2761,7 +2794,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.patch("/git/remotes/:name", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -2796,7 +2829,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.put("/git/remotes/:name/url", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -2834,7 +2867,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/status", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -2878,7 +2911,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/commits", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -2901,7 +2934,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/commits/:hash/diff", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -2931,7 +2964,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/commits/ahead", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -2955,7 +2988,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/remotes/:name/commits", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3024,7 +3057,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/branches", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3047,7 +3080,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/branches/:name/commits", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3074,7 +3107,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/worktrees", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3100,7 +3133,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/branches", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3131,7 +3164,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/branches/:name/checkout", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3160,7 +3193,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.delete("/git/branches/:name", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3192,7 +3225,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/fetch", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3220,7 +3253,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/pull", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3416,7 +3449,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/push", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3445,7 +3478,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/stashes", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3467,7 +3500,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/stashes", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3494,7 +3527,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/stashes/:index/apply", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3520,7 +3553,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/stashes/:index/diff", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3551,7 +3584,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.delete("/git/stashes/:index", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3576,7 +3609,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/diff", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3598,7 +3631,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/diff/file", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3633,7 +3666,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/git/changes", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3655,7 +3688,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/stage", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3681,7 +3714,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/unstage", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3707,7 +3740,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/commit", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3737,7 +3770,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/git/discard", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
if (!(await isGitRepo(rootDir))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
@@ -3764,7 +3797,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/github/issues/recent", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
|
||||
const remotes = await getGitHubRemotes(rootDir);
|
||||
const remote = remotes.find((item) => item.name === "origin") ?? remotes[0];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user