feat: Redesign git changes to split stage/unstaged files. (#1359)

* feat: Redesign git changes to split stage/unstaged files.

Signed-off-by: Paolo Insogna <paolo@cowtech.it>

* fixup

Signed-off-by: Paolo Insogna <paolo@cowtech.it>

* fixup

Signed-off-by: Paolo Insogna <paolo@cowtech.it>

* refactor: streamline git changes panel

* fix: label staged and working diff tabs

* fix: isolate staged and working diff files

* fix: scope staged and working diff updates

* fix: scope git row revert to working changes

---------

Signed-off-by: Paolo Insogna <paolo@cowtech.it>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Paolo Insogna
2026-05-24 00:49:38 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 9af0de0056
commit e16097b05d
42 changed files with 2987 additions and 923 deletions
+9 -2
View File
@@ -30,7 +30,9 @@ The following functions are exported and used by the web server:
- `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs.
- `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs).
- `collectDiffs(directory, files)`: Collect diff output for multiple files.
- `revertFile(directory, filePath)`: Revert a file to HEAD state.
- `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes.
- `stageFile(directory, filePath)`: Add one file path to the index.
- `unstageFile(directory, filePath)`: Remove one file path from the index while preserving working-tree content.
### Branch Operations
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
@@ -48,7 +50,7 @@ The following functions are exported and used by the web server:
- `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
### Commit and Remote Operations
- `commit(directory, message, options)`: Create a commit (supports addAll or specific files).
- `commit(directory, message, options)`: Create a commit from the current index. `options.stageFiles` may be provided with `options.files` by older callers to stage only selected unstaged rows before committing, but the shared Git panel now stages/unstages explicitly before commit.
- `pull(directory, options)`: Pull changes from remote.
- `push(directory, options)`: Push changes to remote (auto-sets upstream if needed).
- `fetch(directory, options)`: Fetch changes from remote.
@@ -105,6 +107,11 @@ The following functions are internal helpers used by exported functions:
- `mergeInProgress`: Object with `{ head, message }` if merge in progress.
- `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
### Staged and unstaged change handling
- `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files.
- A file with both staged and unstaged changes can appear in both UI sections. Staged rows request diffs with `staged: true`; unstaged rows request normal working-tree diffs.
- The shared Git panel exposes explicit staging actions. Unstaged rows use `stageFile`, staged rows use `unstageFile`, and commits operate on the current staged index.
- `stageFiles` remains supported for callers that need to stage a selected unstaged subset as part of commit. In that mode the server temporarily unstages unrelated index entries, stages `stageFiles`, commits from the index, then restores temporarily unstaged entries.
### Worktree Create/Remove Response
- `head`: HEAD commit SHA.
- `name`: Worktree name.
+48 -3
View File
@@ -291,12 +291,12 @@ export function registerGitRoutes(app) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path } = req.body || {};
const { path, scope } = req.body || {};
if (!path || typeof path !== 'string') {
return res.status(400).json({ error: 'path parameter is required' });
}
await revertFile(directory, path);
await revertFile(directory, path, { scope });
res.json({ success: true });
} catch (error) {
console.error('Failed to revert git file:', error);
@@ -304,6 +304,50 @@ export function registerGitRoutes(app) {
}
});
app.post('/api/git/stage', async (req, res) => {
const { stageFiles } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path, paths } = req.body || {};
const filePaths = Array.isArray(paths) ? paths : [path];
if (!filePaths.some((value) => typeof value === 'string' && value.trim())) {
return res.status(400).json({ error: 'path parameter is required' });
}
await stageFiles(directory, filePaths);
res.json({ success: true });
} catch (error) {
console.error('Failed to stage git file:', error);
res.status(500).json({ error: error.message || 'Failed to stage git file' });
}
});
app.post('/api/git/unstage', async (req, res) => {
const { unstageFiles } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path, paths } = req.body || {};
const filePaths = Array.isArray(paths) ? paths : [path];
if (!filePaths.some((value) => typeof value === 'string' && value.trim())) {
return res.status(400).json({ error: 'path parameter is required' });
}
await unstageFiles(directory, filePaths);
res.json({ success: true });
} catch (error) {
console.error('Failed to unstage git file:', error);
res.status(500).json({ error: error.message || 'Failed to unstage git file' });
}
});
app.post('/api/git/pull', async (req, res) => {
const { pull } = await getGitLibraries();
try {
@@ -581,7 +625,7 @@ export function registerGitRoutes(app) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { message, addAll, files } = req.body;
const { message, addAll, files, stageFiles } = req.body;
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
@@ -589,6 +633,7 @@ export function registerGitRoutes(app) {
const result = await commit(directory, message, {
addAll,
files,
stageFiles,
});
res.json(result);
} catch (error) {
+137
View File
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test';
const gitLibraries = {
stageFiles: mock(),
unstageFiles: mock(),
};
mock.module('./index.js', () => ({
stageFiles: gitLibraries.stageFiles,
unstageFiles: gitLibraries.unstageFiles,
}));
const { registerGitRoutes } = await import('./routes.js');
const createRouteRegistry = () => {
const routes = new Map();
return {
app: {
get(routePath, handler) {
routes.set(`GET ${routePath}`, handler);
},
post(routePath, handler) {
routes.set(`POST ${routePath}`, handler);
},
put(routePath, handler) {
routes.set(`PUT ${routePath}`, handler);
},
delete(routePath, handler) {
routes.set(`DELETE ${routePath}`, handler);
},
},
getRoute(method, routePath) {
return routes.get(`${method} ${routePath}`);
},
};
};
const createMockResponse = () => {
let statusCode = 200;
let body = null;
return {
status(code) {
statusCode = code;
return this;
},
json(payload) {
body = payload;
return this;
},
get statusCode() {
return statusCode;
},
get body() {
return body;
},
};
};
describe('git routes index mutations', () => {
beforeEach(() => {
gitLibraries.stageFiles.mockReset();
gitLibraries.unstageFiles.mockReset();
});
it('accepts legacy stage path payloads', async () => {
const { app, getRoute } = createRouteRegistry();
registerGitRoutes(app);
const response = createMockResponse();
await getRoute('POST', '/api/git/stage')(
{ query: { directory: '/repo' }, body: { path: 'a.ts' } },
response,
);
expect(response.statusCode).toBe(200);
expect(gitLibraries.stageFiles).toHaveBeenCalledWith('/repo', ['a.ts']);
});
it('accepts bulk stage paths payloads', async () => {
const { app, getRoute } = createRouteRegistry();
registerGitRoutes(app);
const response = createMockResponse();
await getRoute('POST', '/api/git/stage')(
{ query: { directory: '/repo' }, body: { paths: ['a.ts', 'b.ts'] } },
response,
);
expect(response.statusCode).toBe(200);
expect(gitLibraries.stageFiles).toHaveBeenCalledWith('/repo', ['a.ts', 'b.ts']);
});
it('accepts legacy unstage path payloads', async () => {
const { app, getRoute } = createRouteRegistry();
registerGitRoutes(app);
const response = createMockResponse();
await getRoute('POST', '/api/git/unstage')(
{ query: { directory: '/repo' }, body: { path: 'a.ts' } },
response,
);
expect(response.statusCode).toBe(200);
expect(gitLibraries.unstageFiles).toHaveBeenCalledWith('/repo', ['a.ts']);
});
it('accepts bulk unstage paths payloads', async () => {
const { app, getRoute } = createRouteRegistry();
registerGitRoutes(app);
const response = createMockResponse();
await getRoute('POST', '/api/git/unstage')(
{ query: { directory: '/repo' }, body: { paths: ['a.ts', 'b.ts'] } },
response,
);
expect(response.statusCode).toBe(200);
expect(gitLibraries.unstageFiles).toHaveBeenCalledWith('/repo', ['a.ts', 'b.ts']);
});
it('rejects invalid path payloads before calling git', async () => {
const { app, getRoute } = createRouteRegistry();
registerGitRoutes(app);
const response = createMockResponse();
await getRoute('POST', '/api/git/stage')(
{ query: { directory: '/repo' }, body: { paths: [' ', null] } },
response,
);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error: 'path parameter is required' });
expect(gitLibraries.stageFiles).not.toHaveBeenCalled();
});
});
+305 -104
View File
@@ -12,6 +12,7 @@ const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
let resolvedGitBinary = null;
const worktreeBootstrapState = new Map();
const gitIndexMutationQueues = new Map();
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
const WORKTREE_BOOTSTRAP_READY = 'ready';
@@ -307,6 +308,60 @@ const normalizeDirectoryPath = (value) => {
return trimmed;
};
const getGitIndexMutationQueueKey = (directory) => {
const normalized = normalizeDirectoryPath(directory);
if (!normalized) {
return '';
}
return path.resolve(normalized);
};
const withGitIndexMutationQueue = async (directory, task) => {
let key = getGitIndexMutationQueueKey(directory);
try {
const directoryPath = normalizeDirectoryPath(directory);
if (directoryPath) {
const git = await createGit(directoryPath);
key = await resolveGitRepositoryRoot(directoryPath, git);
}
} catch {
// Fall back to the normalized directory key when the repo root is unavailable.
}
if (!key) {
return task();
}
const previous = gitIndexMutationQueues.get(key) || Promise.resolve();
const current = previous.catch(() => {}).then(task);
const tail = current.catch(() => {});
gitIndexMutationQueues.set(key, tail);
try {
return await current;
} finally {
if (gitIndexMutationQueues.get(key) === tail) {
gitIndexMutationQueues.delete(key);
}
}
};
const normalizeFilePathList = (paths) => Array.from(new Set(
(Array.isArray(paths) ? paths : [paths])
.map((value) => String(value || '').trim())
.filter(Boolean)
));
const validateRepositoryFilePaths = (directoryPath, filePaths) => {
const repoRoot = path.resolve(directoryPath);
for (const filePath of filePaths) {
const absoluteTarget = path.resolve(repoRoot, filePath);
if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
throw new Error(`Path is outside repository: ${filePath}`);
}
}
};
const toGitPath = (value) => value.replace(/\\/g, '/');
const isInsideOrSameDirectory = (root, target) => {
@@ -1811,14 +1866,30 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
let modified = '';
try {
const stat = await fsp.stat(absolutePath);
if (stat.isFile()) {
if (staged) {
if (isImage) {
// For images, read as binary and convert to data URL
const buffer = await fsp.readFile(absolutePath);
modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
const { stdout } = await execFileAsync(getGitBinary(), ['show', `:${repoPath}`], {
cwd: repoRoot,
encoding: 'buffer',
windowsHide: true,
maxBuffer: 50 * 1024 * 1024,
});
if (stdout && stdout.length > 0) {
modified = `data:${mimeType};base64,${stdout.toString('base64')}`;
}
} else {
modified = await fsp.readFile(absolutePath, 'utf8');
modified = await git.show([`:${repoPath}`]);
}
} else {
const stat = await fsp.stat(absolutePath);
if (stat.isFile()) {
if (isImage) {
// For images, read as binary and convert to data URL
const buffer = await fsp.readFile(absolutePath);
modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
} else {
modified = await fsp.readFile(absolutePath, 'utf8');
}
}
}
} catch (error) {
@@ -1838,52 +1909,57 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
};
}
export async function revertFile(directory, filePath) {
const directoryPath = normalizeDirectoryPath(directory);
const directoryGit = await createGit(directoryPath);
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
const git = await createGit(repoRoot);
export async function revertFile(directory, filePath, options = {}) {
return withGitIndexMutationQueue(directory, async () => {
const scope = options?.scope === 'working' ? 'working' : 'all';
const directoryPath = normalizeDirectoryPath(directory);
const directoryGit = await createGit(directoryPath);
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
const git = await createGit(repoRoot);
const isTracked = await git
.raw(['ls-files', '--error-unmatch', '--', repoPath])
.then(() => true)
.catch(() => false);
const isTracked = await git
.raw(['ls-files', '--error-unmatch', '--', repoPath])
.then(() => true)
.catch(() => false);
if (!isTracked) {
try {
await git.raw(['clean', '-f', '-d', '--', repoPath]);
return;
} catch (cleanError) {
if (!isTracked) {
try {
await fsp.rm(absolutePath, { recursive: true, force: true });
await git.raw(['clean', '-f', '-d', '--', repoPath]);
return;
} catch (fsError) {
if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
} catch (cleanError) {
try {
await fsp.rm(absolutePath, { recursive: true, force: true });
return;
} catch (fsError) {
if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
return;
}
console.error('Failed to remove untracked file during revert:', fsError);
throw fsError;
}
console.error('Failed to remove untracked file during revert:', fsError);
throw fsError;
}
}
}
try {
await git.raw(['restore', '--staged', '--', repoPath]);
} catch (error) {
await git.raw(['reset', 'HEAD', '--', repoPath]).catch(() => {});
}
try {
await git.raw(['restore', '--', repoPath]);
} catch (error) {
try {
await git.raw(['checkout', '--', repoPath]);
} catch (fallbackError) {
console.error('Failed to revert git file:', fallbackError);
throw fallbackError;
if (scope === 'all') {
try {
await git.raw(['restore', '--staged', '--', repoPath]);
} catch (error) {
await git.raw(['reset', 'HEAD', '--', repoPath]).catch(() => {});
}
}
}
try {
await git.raw(['restore', '--', repoPath]);
} catch (error) {
try {
await git.raw(['checkout', '--', repoPath]);
} catch (fallbackError) {
console.error('Failed to revert git file:', fallbackError);
throw fallbackError;
}
}
});
}
export async function collectDiffs(directory, files = []) {
@@ -1981,7 +2057,12 @@ export async function stashPush(directory, options = {}) {
export async function stashApply(directory, options = {}) {
const { git } = await createRepositoryGitContext(directory);
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
await git.raw(['stash', 'apply', ref]);
// Prefer --index so the staged/unstaged split captured in the stash is restored
// faithfully. Fall back to a plain apply when the index can't be reinstated
// cleanly (e.g. conflicts), which is the prior behavior.
await git.raw(['stash', 'apply', '--index', ref]).catch(async () => {
await git.raw(['stash', 'apply', ref]);
});
return { success: true, ref };
}
@@ -2172,77 +2253,197 @@ export async function fetch(directory, options = {}) {
}
}
export async function commit(directory, message, options = {}) {
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
export async function stageFile(directory, filePath) {
await stageFiles(directory, [filePath]);
}
try {
const requestedFiles = Array.isArray(options.files)
? options.files
.map((value) => String(value || '').trim())
.filter(Boolean)
: [];
let filesToCommit = [];
export async function stageFiles(directory, paths) {
if (!directory) {
throw new Error('directory and path are required for stageFile');
}
if (options.addAll) {
await git.add('.');
} else if (requestedFiles.length > 0) {
filesToCommit = Array.from(new Set(await Promise.all(requestedFiles.map(async (filePath) => {
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
return fileContext.repoPath;
}))));
const filePaths = normalizeFilePathList(paths);
if (filePaths.length === 0) {
throw new Error('directory and path are required for stageFile');
}
validateRepositoryFilePaths(normalizeDirectoryPath(directory), filePaths);
const status = await git.status();
const fileStatusByPath = new Map(status.files.map((file) => [file.path, file]));
filesToCommit = filesToCommit.filter((filePath) => fileStatusByPath.has(filePath));
if (filesToCommit.length === 0) {
throw new Error('No selected files are available to commit. Refresh git status and try again.');
}
const filesNeedingAdd = filesToCommit.filter((filePath) => {
const fileStatus = fileStatusByPath.get(filePath);
if (!fileStatus) {
return false;
}
const alreadyFullyStaged = fileStatus.index !== ' ' && fileStatus.working_dir === ' ';
return !alreadyFullyStaged;
});
if (filesNeedingAdd.length > 0) {
await git.add(filesNeedingAdd);
}
}
const commitArgs =
!options.addAll && filesToCommit.length > 0
? filesToCommit
: undefined;
let result;
try {
result = await git.commit(message, commitArgs);
} catch (error) {
await withGitIndexMutationQueue(directory, async () => {
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
const repoPaths = Array.from(new Set(await Promise.all(filePaths.map(async (filePath) => {
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
return fileContext.repoPath;
}))));
validateRepositoryFilePaths(repoRoot, repoPaths);
await git.raw(['add', '--', ...repoPaths]).catch(async (error) => {
const gitErrorText = parseGitErrorText(error);
const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files');
if (!isPathspecError || !commitArgs || commitArgs.length === 0) {
if (!isPathspecError) {
throw error;
}
// Fallback for deleted/stale selections: commit currently staged changes.
result = await git.commit(message);
}
// During rapid stage/unstage toggling the optimistic UI can request staging a
// path that a prior queued mutation already staged (most visibly a deletion,
// whose file is gone from the working tree). `git add` aborts the whole batch
// on a single unmatched pathspec, so retry per-path and skip the ones already
// in their target state rather than failing the entire "stage all".
for (const repoPath of repoPaths) {
await git.raw(['add', '--', repoPath]).catch((perPathError) => {
const perPathText = parseGitErrorText(perPathError);
const perPathIsPathspecError =
perPathText.includes('pathspec') && perPathText.includes('did not match any files');
if (!perPathIsPathspecError) {
throw perPathError;
}
});
}
});
});
}
return {
success: true,
commit: result.commit,
branch: result.branch,
summary: result.summary
};
} catch (error) {
console.error('Failed to commit:', error);
throw error;
export async function unstageFile(directory, filePath) {
await unstageFiles(directory, [filePath]);
}
export async function unstageFiles(directory, paths) {
if (!directory) {
throw new Error('directory and path are required for unstageFile');
}
const filePaths = normalizeFilePathList(paths);
if (filePaths.length === 0) {
throw new Error('directory and path are required for unstageFile');
}
validateRepositoryFilePaths(normalizeDirectoryPath(directory), filePaths);
await withGitIndexMutationQueue(directory, async () => {
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
const repoPaths = Array.from(new Set(await Promise.all(filePaths.map(async (filePath) => {
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
return fileContext.repoPath;
}))));
validateRepositoryFilePaths(repoRoot, repoPaths);
await git.raw(['restore', '--staged', '--', ...repoPaths]).catch(async () => {
await git.raw(['reset', 'HEAD', '--', ...repoPaths]);
});
});
}
export async function commit(directory, message, options = {}) {
return withGitIndexMutationQueue(directory, async () => {
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
let temporarilyUnstagedFiles = [];
try {
const requestedFiles = Array.isArray(options.files)
? options.files
.map((value) => String(value || '').trim())
.filter(Boolean)
: [];
const requestedStageFiles = Array.isArray(options.stageFiles)
? options.stageFiles
.map((value) => String(value || '').trim())
.filter(Boolean)
: null;
let filesToCommit = [];
let commitFromIndexOnly = false;
if (options.addAll) {
await git.add('.');
} else if (requestedFiles.length > 0) {
filesToCommit = Array.from(new Set(await Promise.all(requestedFiles.map(async (filePath) => {
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
return fileContext.repoPath;
}))));
const stageFilesToCommit = requestedStageFiles
? Array.from(new Set(await Promise.all(requestedStageFiles.map(async (filePath) => {
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
return fileContext.repoPath;
}))))
: null;
const status = await git.status();
const fileStatusByPath = new Map(status.files.map((file) => [file.path, file]));
filesToCommit = filesToCommit.filter((filePath) => fileStatusByPath.has(filePath));
if (filesToCommit.length === 0) {
throw new Error('No selected files are available to commit. Refresh git status and try again.');
}
if (requestedStageFiles) {
commitFromIndexOnly = true;
const selectedFileSet = new Set(filesToCommit);
temporarilyUnstagedFiles = status.files
.filter((file) => {
const indexStatus = (file.index || '').trim();
return indexStatus && indexStatus !== '?' && !selectedFileSet.has(file.path);
})
.map((file) => file.path);
if (temporarilyUnstagedFiles.length > 0) {
await git.raw(['restore', '--staged', '--', ...temporarilyUnstagedFiles]);
}
}
const filesNeedingAdd = requestedStageFiles
? (stageFilesToCommit || []).filter((filePath) => fileStatusByPath.has(filePath))
: filesToCommit.filter((filePath) => {
const fileStatus = fileStatusByPath.get(filePath);
if (!fileStatus) {
return false;
}
const alreadyFullyStaged = fileStatus.index !== ' ' && fileStatus.working_dir === ' ';
return !alreadyFullyStaged;
});
if (filesNeedingAdd.length > 0) {
await git.raw(['add', '--', ...filesNeedingAdd]);
}
}
const commitArgs =
!commitFromIndexOnly && !options.addAll && filesToCommit.length > 0
? filesToCommit
: undefined;
let result;
try {
result = await git.commit(message, commitArgs);
} catch (error) {
const gitErrorText = parseGitErrorText(error);
const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files');
if (!isPathspecError || !commitArgs || commitArgs.length === 0) {
throw error;
}
// Fallback for deleted/stale selections: commit currently staged changes.
result = await git.commit(message);
}
if (temporarilyUnstagedFiles.length > 0) {
await git.raw(['add', '--', ...temporarilyUnstagedFiles]).catch((restoreError) => {
console.error('Failed to restore temporarily unstaged files:', restoreError);
});
}
return {
success: true,
commit: result.commit,
branch: result.branch,
summary: result.summary
};
} catch (error) {
if (temporarilyUnstagedFiles.length > 0) {
await git.raw(['add', '--', ...temporarilyUnstagedFiles]).catch((restoreError) => {
console.error('Failed to restore temporarily unstaged files after commit failure:', restoreError);
});
}
console.error('Failed to commit:', error);
throw error;
}
});
}
export async function getBranches(directory) {
+11 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { resolveBaseRefForLog } from './service.js';
import { resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js';
describe('resolveBaseRefForLog', () => {
it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
@@ -37,3 +37,13 @@ describe('resolveBaseRefForLog', () => {
expect(await resolveBaseRefForLog(' ', checkRef)).toBeUndefined();
});
});
describe('git index path validation', () => {
it('rejects stage paths outside the repository before invoking git', async () => {
await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
});
it('rejects unstage paths outside the repository before invoking git', async () => {
await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('@openchamber/ui/lib/gitApiHttp', () => ({
checkIsGitRepository: vi.fn(),
getGitStatus: vi.fn(),
getGitDiff: vi.fn(),
getGitFileDiff: vi.fn(),
revertGitFile: vi.fn(),
stageGitFile: vi.fn(),
stageGitFiles: vi.fn(),
unstageGitFile: vi.fn(),
unstageGitFiles: vi.fn(),
isLinkedWorktree: vi.fn(),
getGitBranches: vi.fn(),
deleteGitBranch: vi.fn(),
deleteRemoteBranch: vi.fn(),
removeRemote: vi.fn(),
generateCommitMessage: vi.fn(),
generatePullRequestDescription: vi.fn(),
listGitWorktrees: vi.fn(),
validateGitWorktree: vi.fn(),
createGitWorktree: vi.fn(),
deleteGitWorktree: vi.fn(),
validateWorktreeDirectory: vi.fn(),
canonicalizeWorktreeState: vi.fn(),
createGitCommit: vi.fn(),
gitPush: vi.fn(),
gitPull: vi.fn(),
gitFetch: vi.fn(),
listGitStashes: vi.fn(),
countGitStashFiles: vi.fn(),
stashGitChanges: vi.fn(),
applyGitStash: vi.fn(),
popGitStash: vi.fn(),
dropGitStash: vi.fn(),
checkoutBranch: vi.fn(),
createBranch: vi.fn(),
renameBranch: vi.fn(),
getGitLog: vi.fn(),
getCommitFiles: vi.fn(),
getCurrentGitIdentity: vi.fn(),
hasLocalIdentity: vi.fn(),
setGitIdentity: vi.fn(),
getGitIdentities: vi.fn(),
createGitIdentity: vi.fn(),
updateGitIdentity: vi.fn(),
deleteGitIdentity: vi.fn(),
getRemotes: vi.fn(),
rebase: vi.fn(),
abortRebase: vi.fn(),
continueRebase: vi.fn(),
merge: vi.fn(),
abortMerge: vi.fn(),
continueMerge: vi.fn(),
stash: vi.fn(),
stashPop: vi.fn(),
getConflictDetails: vi.fn(),
}));
describe('createWebGitAPI', () => {
it('exposes bulk stage and unstage methods', async () => {
const { createWebGitAPI } = await import('./git');
const api = createWebGitAPI();
expect(typeof api.stageGitFiles).toBe('function');
expect(typeof api.unstageGitFiles).toBe('function');
});
});
+4
View File
@@ -11,6 +11,10 @@ export const createWebGitAPI = (): GitAPI => ({
getGitDiff: gitApiHttp.getGitDiff,
getGitFileDiff: gitApiHttp.getGitFileDiff,
revertGitFile: gitApiHttp.revertGitFile,
stageGitFile: gitApiHttp.stageGitFile,
stageGitFiles: gitApiHttp.stageGitFiles,
unstageGitFile: gitApiHttp.unstageGitFile,
unstageGitFiles: gitApiHttp.unstageGitFiles,
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
getGitBranches: gitApiHttp.getGitBranches,
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],