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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
9af0de0056
commit
e16097b05d
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user