feat(sdk): add --respect-staged opt-in to gsd-sdk query commit --files (#3522)
Closes #3522. When --respect-staged is passed the git add loop is skipped entirely so per-hunk staging from git add -p is preserved. Default behavior (full re-stage) is unchanged. The #3061 pathspec invariant holds under both modes. Nothing-staged within scope returns { committed: false, reason: 'nothing staged' } without error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -339,3 +339,124 @@ describe('commit input validation and option safety (#3061)', () => {
|
||||
expect(committed).toContain(dashName);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── --respect-staged flag (#3522) ────────────────────────────────────────
|
||||
//
|
||||
// When --respect-staged is passed:
|
||||
// 1. The git add loop is skipped entirely — partial (per-hunk) staging is
|
||||
// preserved rather than overwritten by a full `git add -- <file>`.
|
||||
// 2. If nothing is staged within the requested pathspec, the handler
|
||||
// returns { committed: false, reason: 'nothing staged' } without error.
|
||||
// 3. The #3061 invariant still holds: files staged *outside* --files <paths>
|
||||
// do not appear in the commit (trailing -- pathspec still applied).
|
||||
// 4. Without --respect-staged, --files re-stages the full file as before
|
||||
// (back-compat: partial staging is overwritten).
|
||||
|
||||
describe('commit --respect-staged (#3522)', () => {
|
||||
// Each test needs an existing HEAD so we can create tracked files and
|
||||
// simulate `git add -p` style partial staging.
|
||||
beforeEach(async () => {
|
||||
await writeFile(join(tmpDir, 'README.md'), 'init\n');
|
||||
execSync('git add README.md', { cwd: tmpDir, stdio: 'pipe' });
|
||||
execSync('git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ commit_docs: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('commits only the pre-staged file when --respect-staged is passed (simulated git add -p)', async () => {
|
||||
const { commit } = await import('./commit.js');
|
||||
|
||||
// Simulate a `git add -p` scenario using two files:
|
||||
// - staged-hunk.ts: the hunk the operator chose to stage (staged beforehand)
|
||||
// - unstaged-hunk.ts: the hunk the operator left out of the index
|
||||
// Both paths are listed in --files. Without --respect-staged the handler
|
||||
// would git add both, committing all working-tree changes. With
|
||||
// --respect-staged it must commit only what is already in the index.
|
||||
await writeFile(join(tmpDir, 'staged-hunk.ts'), '// selected hunk\nconst a = 1;\n');
|
||||
await writeFile(join(tmpDir, 'unstaged-hunk.ts'), '// skipped hunk\nconst b = 2;\n');
|
||||
|
||||
// Stage only the selected hunk — the skipped hunk stays in the work tree.
|
||||
execSync('git add staged-hunk.ts', { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
const result = await commit(
|
||||
['feat: partial hunk commit', '--files', 'staged-hunk.ts', 'unstaged-hunk.ts', '--respect-staged'],
|
||||
tmpDir,
|
||||
);
|
||||
expect((result.data as { committed: boolean }).committed).toBe(true);
|
||||
|
||||
const committedFiles = execSync('git show --name-only --format= HEAD', { cwd: tmpDir, encoding: 'utf-8' })
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
// Only the pre-staged hunk must appear in the commit.
|
||||
expect(committedFiles).toContain('staged-hunk.ts');
|
||||
expect(committedFiles).not.toContain('unstaged-hunk.ts');
|
||||
});
|
||||
|
||||
it('returns { committed: false, reason: "nothing staged" } when --respect-staged has nothing staged in pathspec', async () => {
|
||||
const { commit } = await import('./commit.js');
|
||||
|
||||
// Create a file but do NOT stage it.
|
||||
await writeFile(join(tmpDir, 'unstaged.ts'), '// not staged\n');
|
||||
|
||||
const result = await commit(
|
||||
['feat: should not commit', '--files', 'unstaged.ts', '--respect-staged'],
|
||||
tmpDir,
|
||||
);
|
||||
expect((result.data as { committed: boolean }).committed).toBe(false);
|
||||
expect((result.data as { reason: string }).reason).toBe('nothing staged');
|
||||
});
|
||||
|
||||
it('files staged outside --files pathspec are excluded under --respect-staged (#3061 invariant)', async () => {
|
||||
const { commit } = await import('./commit.js');
|
||||
|
||||
// Stage an "in-scope" file and an "out-of-scope" file.
|
||||
await writeFile(join(tmpDir, 'in-scope.ts'), '// in scope\n');
|
||||
await writeFile(join(tmpDir, 'out-of-scope.ts'), '// out of scope\n');
|
||||
execSync('git add in-scope.ts out-of-scope.ts', { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
// --files only names in-scope.ts. --respect-staged skips git add.
|
||||
const result = await commit(
|
||||
['feat: scoped commit', '--files', 'in-scope.ts', '--respect-staged'],
|
||||
tmpDir,
|
||||
);
|
||||
expect((result.data as { committed: boolean }).committed).toBe(true);
|
||||
|
||||
const committedFiles = execSync('git show --name-only --format= HEAD', { cwd: tmpDir, encoding: 'utf-8' })
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
expect(committedFiles).toContain('in-scope.ts');
|
||||
expect(committedFiles).not.toContain('out-of-scope.ts');
|
||||
|
||||
// out-of-scope.ts must remain staged (not committed, not lost).
|
||||
const staged = execSync('git diff --cached --name-only', { cwd: tmpDir, encoding: 'utf-8' }).trim();
|
||||
expect(staged).toContain('out-of-scope.ts');
|
||||
});
|
||||
|
||||
it('without --respect-staged, --files re-stages the full file even when partially pre-staged (back-compat)', async () => {
|
||||
const { commit } = await import('./commit.js');
|
||||
|
||||
// Write a file, land it, then modify it in working tree without staging.
|
||||
await writeFile(join(tmpDir, 'full-file.ts'), '// original\n');
|
||||
execSync('git add full-file.ts', { cwd: tmpDir, stdio: 'pipe' });
|
||||
execSync('git commit -m "land file"', { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
// Unstaged modification in the working tree.
|
||||
await writeFile(join(tmpDir, 'full-file.ts'), '// modified in working tree\n');
|
||||
|
||||
// Without --respect-staged, --files full-file.ts should run git add and
|
||||
// stage the full working-tree content before committing.
|
||||
const result = await commit(
|
||||
['feat: full restage', '--files', 'full-file.ts'],
|
||||
tmpDir,
|
||||
);
|
||||
expect((result.data as { committed: boolean }).committed).toBe(true);
|
||||
|
||||
// The full working-tree change must be in the commit.
|
||||
const diff = execSync('git show HEAD -- full-file.ts', { cwd: tmpDir, encoding: 'utf-8' });
|
||||
expect(diff).toContain('// modified in working tree');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user