feat: add git stash management

Add a Stashes dialog with create, apply, pop, and drop actions
Include untracked files automatically when stashing
Show file counts for current changes and stash entries
This commit is contained in:
Bohdan Triapitsyn
2026-05-05 23:39:20 +03:00
parent c80c2b62a8
commit 93267927ff
19 changed files with 754 additions and 109 deletions
+6 -2
View File
@@ -69,8 +69,12 @@ The following functions are exported and used by the web server:
- `getConflictDetails(directory)`: Get detailed conflict information including operation type, unmerged files, and diff.
### Stash Operations
- `stash(directory, options)`: Stash changes (supports message and includeUntracked options).
- `stashPop(directory)`: Pop and apply the most recent stash.
- `listStashes(directory)`: List stash entries with ref, message, relative time, and hash.
- `countStashFiles(directory, refs)`: Batch-count changed files for stash refs with bounded concurrency.
- `stashPush(directory, options)`: Stash changes, always including untracked files, with optional message.
- `stashApply(directory, options)`: Apply a stash by ref without removing it.
- `stashPop(directory, options)`: Apply a stash by ref and drop it only after a successful apply.
- `stashDrop(directory, options)`: Drop a stash by ref.
## Internal Helpers
+72 -32
View File
@@ -336,6 +336,78 @@ export function registerGitRoutes(app) {
}
});
app.get('/api/git/stashes', async (req, res) => {
const { listStashes } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) return res.status(400).json({ error: 'directory parameter is required' });
res.json({ stashes: await listStashes(directory) });
} catch (error) {
console.error('Failed to list stashes:', error);
res.status(500).json({ error: error.message || 'Failed to list stashes' });
}
});
app.post('/api/git/stashes/file-counts', async (req, res) => {
const { countStashFiles } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) return res.status(400).json({ error: 'directory parameter is required' });
res.json({ counts: await countStashFiles(directory, req.body?.refs) });
} catch (error) {
console.error('Failed to count stash files:', error);
res.status(500).json({ error: error.message || 'Failed to count stash files' });
}
});
app.post('/api/git/stash', async (req, res) => {
const { stashPush } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) return res.status(400).json({ error: 'directory parameter is required' });
res.json(await stashPush(directory, req.body));
} catch (error) {
console.error('Failed to stash changes:', error);
res.status(500).json({ error: error.message || 'Failed to stash changes' });
}
});
app.post('/api/git/stash/apply', async (req, res) => {
const { stashApply } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) return res.status(400).json({ error: 'directory parameter is required' });
res.json(await stashApply(directory, req.body));
} catch (error) {
console.error('Failed to apply stash:', error);
res.status(500).json({ error: error.message || 'Failed to apply stash' });
}
});
app.post('/api/git/stash/pop', async (req, res) => {
const { stashPop } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) return res.status(400).json({ error: 'directory parameter is required' });
res.json(await stashPop(directory, req.body));
} catch (error) {
console.error('Failed to pop stash:', error);
res.status(500).json({ error: error.message || 'Failed to pop stash' });
}
});
app.post('/api/git/stash/drop', async (req, res) => {
const { stashDrop } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) return res.status(400).json({ error: 'directory parameter is required' });
res.json(await stashDrop(directory, req.body));
} catch (error) {
console.error('Failed to drop stash:', error);
res.status(500).json({ error: error.message || 'Failed to drop stash' });
}
});
app.post('/api/git/fetch', async (req, res) => {
const { fetch: gitFetch } = await getGitLibraries();
try {
@@ -501,38 +573,6 @@ export function registerGitRoutes(app) {
}
});
app.post('/api/git/stash', async (req, res) => {
const { stash } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await stash(directory, req.body);
res.json(result);
} catch (error) {
console.error('Failed to stash:', error);
res.status(500).json({ error: error.message || 'Failed to stash' });
}
});
app.post('/api/git/stash/pop', async (req, res) => {
const { stashPop } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await stashPop(directory);
res.json(result);
} catch (error) {
console.error('Failed to pop stash:', error);
res.status(500).json({ error: error.message || 'Failed to pop stash' });
}
});
app.post('/api/git/commit', async (req, res) => {
const { commit } = await getGitLibraries();
try {
+72 -37
View File
@@ -1828,6 +1828,78 @@ export async function pull(directory, options = {}) {
}
}
export async function listStashes(directory) {
const git = await createGit(directory);
const output = await git.raw(['stash', 'list', '--format=%gd%x1f%gs%x1f%cr%x1f%H']);
return String(output || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [ref = '', message = '', relativeTime = '', hash = ''] = line.split('\x1f');
return { ref, message, relativeTime, hash };
})
.filter((entry) => entry.ref);
}
export async function countStashFiles(directory, refs = []) {
const git = await createGit(directory);
const uniqueRefs = Array.from(new Set((Array.isArray(refs) ? refs : []).map((ref) => String(ref || '').trim()).filter(Boolean)));
const counts = {};
const concurrency = 4;
let cursor = 0;
const worker = async () => {
while (cursor < uniqueRefs.length) {
const ref = uniqueRefs[cursor++];
if (!ref) continue;
try {
const names = await git.raw(['stash', 'show', '--name-only', ref]);
counts[ref] = String(names || '').split('\n').map((line) => line.trim()).filter(Boolean).length;
} catch {
counts[ref] = 0;
}
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, uniqueRefs.length) }, () => worker()));
return counts;
}
export async function stashPush(directory, options = {}) {
const git = await createGit(directory);
const message = typeof options.message === 'string' && options.message.trim()
? options.message.trim()
: `OpenChamber stash ${new Date().toISOString()}`;
const output = await git.raw(['stash', 'push', '--include-untracked', '-m', message]);
return {
success: true,
created: !/no local changes/i.test(String(output || '')),
message,
output: String(output || '').trim(),
};
}
export async function stashApply(directory, options = {}) {
const git = await createGit(directory);
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
await git.raw(['stash', 'apply', ref]);
return { success: true, ref };
}
export async function stashDrop(directory, options = {}) {
const git = await createGit(directory);
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
await git.raw(['stash', 'drop', ref]);
return { success: true, ref };
}
export async function stashPop(directory, options = {}) {
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
await stashApply(directory, { ref });
await stashDrop(directory, { ref });
return { success: true, ref };
}
export async function push(directory, options = {}) {
const git = await createGit(directory);
@@ -3267,40 +3339,3 @@ export async function getConflictDetails(directory) {
throw error;
}
}
// ============== Stash Operations ==============
export async function stash(directory, options = {}) {
const git = await createGit(directory);
try {
const args = ['stash', 'push'];
// Include untracked files by default
if (options.includeUntracked !== false) {
args.push('--include-untracked');
}
if (options.message) {
args.push('-m', options.message);
}
await git.raw(args);
return { success: true };
} catch (error) {
console.error('Failed to stash:', error);
throw error;
}
}
export async function stashPop(directory) {
const git = await createGit(directory);
try {
await git.raw(['stash', 'pop']);
return { success: true };
} catch (error) {
console.error('Failed to pop stash:', error);
throw error;
}
}
+6
View File
@@ -30,6 +30,12 @@ export const createWebGitAPI = (): GitAPI => ({
gitPush: gitApiHttp.gitPush,
gitPull: gitApiHttp.gitPull,
gitFetch: gitApiHttp.gitFetch,
listGitStashes: gitApiHttp.listGitStashes,
countGitStashFiles: gitApiHttp.countGitStashFiles,
stashGitChanges: gitApiHttp.stashGitChanges,
applyGitStash: gitApiHttp.applyGitStash,
popGitStash: gitApiHttp.popGitStash,
dropGitStash: gitApiHttp.dropGitStash,
checkoutBranch: gitApiHttp.checkoutBranch,
createBranch: gitApiHttp.createBranch,
renameBranch: gitApiHttp.renameBranch,