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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user