@orderful/droid 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +6 -4
- package/AGENTS.md +58 -0
- package/CHANGELOG.md +25 -0
- package/README.md +11 -6
- package/dist/bin/droid.js +384 -170
- package/dist/commands/config.d.ts +15 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/exec.d.ts +10 -0
- package/dist/commands/exec.d.ts.map +1 -0
- package/dist/commands/tui.d.ts.map +1 -1
- package/dist/index.js +171 -33
- package/dist/lib/migrations.d.ts.map +1 -1
- package/dist/lib/skills.d.ts.map +1 -1
- package/dist/tools/codex/TOOL.yaml +1 -1
- package/dist/tools/codex/skills/droid-codex/SKILL.md +81 -65
- package/dist/tools/codex/skills/droid-codex/references/creating.md +13 -51
- package/dist/tools/codex/skills/droid-codex/references/decisions.md +15 -19
- package/dist/tools/codex/skills/droid-codex/references/topics.md +14 -12
- package/dist/tools/codex/skills/droid-codex/scripts/git-finish-write.d.ts +31 -0
- package/dist/tools/codex/skills/droid-codex/scripts/git-finish-write.d.ts.map +1 -0
- package/dist/tools/codex/skills/droid-codex/scripts/git-finish-write.ts +236 -0
- package/dist/tools/codex/skills/droid-codex/scripts/git-preamble.d.ts +20 -0
- package/dist/tools/codex/skills/droid-codex/scripts/git-preamble.d.ts.map +1 -0
- package/dist/tools/codex/skills/droid-codex/scripts/git-preamble.ts +156 -0
- package/dist/tools/codex/skills/droid-codex/scripts/git-scripts.test.ts +364 -0
- package/dist/tools/codex/skills/droid-codex/scripts/git-start-write.d.ts +23 -0
- package/dist/tools/codex/skills/droid-codex/scripts/git-start-write.d.ts.map +1 -0
- package/dist/tools/codex/skills/droid-codex/scripts/git-start-write.ts +172 -0
- package/package.json +1 -1
- package/src/bin/droid.ts +9 -0
- package/src/commands/config.ts +38 -4
- package/src/commands/exec.ts +96 -0
- package/src/commands/install.ts +1 -1
- package/src/commands/setup.ts +6 -6
- package/src/commands/tui.tsx +254 -175
- package/src/lib/migrations.ts +103 -10
- package/src/lib/quotes.ts +6 -6
- package/src/lib/skills.ts +168 -45
- package/src/tools/codex/TOOL.yaml +1 -1
- package/src/tools/codex/skills/droid-codex/SKILL.md +81 -65
- package/src/tools/codex/skills/droid-codex/references/creating.md +13 -51
- package/src/tools/codex/skills/droid-codex/references/decisions.md +15 -19
- package/src/tools/codex/skills/droid-codex/references/topics.md +14 -12
- package/src/tools/codex/skills/droid-codex/scripts/git-finish-write.ts +236 -0
- package/src/tools/codex/skills/droid-codex/scripts/git-preamble.ts +156 -0
- package/src/tools/codex/skills/droid-codex/scripts/git-scripts.test.ts +364 -0
- package/src/tools/codex/skills/droid-codex/scripts/git-start-write.ts +172 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* codex git-preamble
|
|
4
|
+
*
|
|
5
|
+
* Ensures the codex repo is on a clean main branch with latest changes.
|
|
6
|
+
* Run this before ANY codex operation (read or write).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* droid config codex | droid exec droid-codex git-preamble --config -
|
|
10
|
+
*
|
|
11
|
+
* What it does:
|
|
12
|
+
* 1. Checkout main (abort any stuck merge/rebase)
|
|
13
|
+
* 2. Stash any uncommitted changes
|
|
14
|
+
* 3. Pull latest with rebase
|
|
15
|
+
*
|
|
16
|
+
* Output (JSON):
|
|
17
|
+
* { "success": true, "branch": "main", "stashed": false }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execSync } from 'child_process';
|
|
21
|
+
import { readFileSync, existsSync } from 'fs';
|
|
22
|
+
|
|
23
|
+
interface Config {
|
|
24
|
+
codex_repo: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Result {
|
|
28
|
+
success: boolean;
|
|
29
|
+
branch?: string;
|
|
30
|
+
stashed?: boolean;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs(args: string[]): { config: Config | null } {
|
|
35
|
+
let config: Config | null = null;
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < args.length; i++) {
|
|
38
|
+
const arg = args[i];
|
|
39
|
+
if (arg === '--config' && args[i + 1]) {
|
|
40
|
+
const configArg = args[++i];
|
|
41
|
+
if (configArg === '-') {
|
|
42
|
+
const stdin = readFileSync(0, 'utf-8').trim();
|
|
43
|
+
config = JSON.parse(stdin) as Config;
|
|
44
|
+
} else {
|
|
45
|
+
config = JSON.parse(configArg) as Config;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { config };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function expandPath(p: string): string {
|
|
54
|
+
if (p.startsWith('~/')) {
|
|
55
|
+
return p.replace('~', process.env.HOME || '');
|
|
56
|
+
}
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function run(cmd: string, cwd: string): { ok: boolean; output: string } {
|
|
61
|
+
try {
|
|
62
|
+
const output = execSync(cmd, {
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: 'utf-8',
|
|
65
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
66
|
+
});
|
|
67
|
+
return { ok: true, output: output.trim() };
|
|
68
|
+
} catch (err: unknown) {
|
|
69
|
+
const error = err as { stderr?: string; message?: string };
|
|
70
|
+
return { ok: false, output: error.stderr || error.message || 'Unknown error' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function gitPreamble(repoPath: string): Result {
|
|
75
|
+
const cwd = expandPath(repoPath);
|
|
76
|
+
|
|
77
|
+
// Verify repo exists
|
|
78
|
+
if (!existsSync(cwd)) {
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
error: `Codex repo not found at ${cwd}. Run: git clone git@github.com:orderful/orderful-codex.git ${cwd}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 1. Abort any stuck operations (silent failures are fine)
|
|
86
|
+
run('git merge --abort', cwd);
|
|
87
|
+
run('git rebase --abort', cwd);
|
|
88
|
+
run('git cherry-pick --abort', cwd);
|
|
89
|
+
|
|
90
|
+
// 2. Checkout main
|
|
91
|
+
const checkout = run('git checkout main', cwd);
|
|
92
|
+
if (!checkout.ok && !checkout.output.includes('Already on')) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
error: `Failed to checkout main: ${checkout.output}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 3. Stash any uncommitted changes
|
|
100
|
+
const statusCheck = run('git status --porcelain', cwd);
|
|
101
|
+
let stashed = false;
|
|
102
|
+
if (statusCheck.ok && statusCheck.output.length > 0) {
|
|
103
|
+
const stash = run('git stash push -m "codex-auto-stash"', cwd);
|
|
104
|
+
stashed = stash.ok && !stash.output.includes('No local changes');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 4. Pull latest
|
|
108
|
+
const pull = run('git pull --rebase', cwd);
|
|
109
|
+
if (!pull.ok) {
|
|
110
|
+
// Try to recover - maybe there's a conflict
|
|
111
|
+
run('git rebase --abort', cwd);
|
|
112
|
+
const retryPull = run('git pull --rebase', cwd);
|
|
113
|
+
if (!retryPull.ok) {
|
|
114
|
+
return {
|
|
115
|
+
success: false,
|
|
116
|
+
error: `Failed to pull latest: ${retryPull.output}. The codex repo may have conflicts that need manual resolution.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Get current branch to confirm
|
|
122
|
+
const branch = run('git branch --show-current', cwd);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
success: true,
|
|
126
|
+
branch: branch.ok ? branch.output : 'main',
|
|
127
|
+
stashed,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Main
|
|
132
|
+
const args = process.argv.slice(2);
|
|
133
|
+
const { config } = parseArgs(args);
|
|
134
|
+
|
|
135
|
+
if (!config) {
|
|
136
|
+
console.log(JSON.stringify({
|
|
137
|
+
success: false,
|
|
138
|
+
error: 'Missing --config. Usage: droid config codex | droid exec droid-codex git-preamble --config -',
|
|
139
|
+
}));
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!config.codex_repo) {
|
|
144
|
+
console.log(JSON.stringify({
|
|
145
|
+
success: false,
|
|
146
|
+
error: 'Missing codex_repo in config. Run: droid config codex --set codex_repo=~/path/to/codex',
|
|
147
|
+
}));
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const result = gitPreamble(config.codex_repo);
|
|
152
|
+
console.log(JSON.stringify(result, null, 2));
|
|
153
|
+
|
|
154
|
+
if (!result.success) {
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { execSync, spawnSync } from 'child_process';
|
|
3
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Integration tests for codex git scripts.
|
|
9
|
+
*
|
|
10
|
+
* These tests create real git repos and run the actual scripts,
|
|
11
|
+
* validating both the JSON output and the resulting git state.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SCRIPTS_DIR = __dirname;
|
|
15
|
+
|
|
16
|
+
interface ScriptResult {
|
|
17
|
+
success: boolean;
|
|
18
|
+
branch?: string;
|
|
19
|
+
stashed?: boolean;
|
|
20
|
+
from?: string;
|
|
21
|
+
pr_url?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function runScript(
|
|
26
|
+
scriptName: string,
|
|
27
|
+
args: string[],
|
|
28
|
+
config: Record<string, unknown>
|
|
29
|
+
): ScriptResult {
|
|
30
|
+
const scriptPath = join(SCRIPTS_DIR, `${scriptName}.ts`);
|
|
31
|
+
const configJson = JSON.stringify(config);
|
|
32
|
+
|
|
33
|
+
const result = spawnSync('bun', ['run', scriptPath, '--config', configJson, ...args], {
|
|
34
|
+
encoding: 'utf-8',
|
|
35
|
+
cwd: process.cwd(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (result.error) {
|
|
39
|
+
return { success: false, error: result.error.message };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(result.stdout.trim());
|
|
44
|
+
} catch {
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
error: `Failed to parse output: ${result.stdout}\nStderr: ${result.stderr}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function git(cwd: string, ...args: string[]): string {
|
|
53
|
+
// Quote arguments that contain spaces
|
|
54
|
+
const quotedArgs = args.map(arg => (arg.includes(' ') ? `"${arg}"` : arg));
|
|
55
|
+
return execSync(`git ${quotedArgs.join(' ')}`, { cwd, encoding: 'utf-8' }).trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createTestRepo(): { repo: string; remote: string } {
|
|
59
|
+
// Create temp directories for repo and "remote"
|
|
60
|
+
const baseDir = mkdtempSync(join(tmpdir(), 'codex-test-'));
|
|
61
|
+
const remoteDir = join(baseDir, 'remote.git');
|
|
62
|
+
const repoDir = join(baseDir, 'repo');
|
|
63
|
+
|
|
64
|
+
// Create bare "remote" repo
|
|
65
|
+
mkdirSync(remoteDir);
|
|
66
|
+
git(remoteDir, 'init', '--bare');
|
|
67
|
+
|
|
68
|
+
// Create working repo
|
|
69
|
+
mkdirSync(repoDir);
|
|
70
|
+
git(repoDir, 'init');
|
|
71
|
+
git(repoDir, 'config', 'user.email', 'test@test.com');
|
|
72
|
+
git(repoDir, 'config', 'user.name', 'Test User');
|
|
73
|
+
|
|
74
|
+
// Create initial commit
|
|
75
|
+
writeFileSync(join(repoDir, 'README.md'), '# Test Repo\n');
|
|
76
|
+
git(repoDir, 'add', '.');
|
|
77
|
+
git(repoDir, 'commit', '-m', 'Initial commit');
|
|
78
|
+
|
|
79
|
+
// Rename to main if needed
|
|
80
|
+
try {
|
|
81
|
+
git(repoDir, 'branch', '-M', 'main');
|
|
82
|
+
} catch {
|
|
83
|
+
// Already on main
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Add remote and push
|
|
87
|
+
git(repoDir, 'remote', 'add', 'origin', remoteDir);
|
|
88
|
+
git(repoDir, 'push', '-u', 'origin', 'main');
|
|
89
|
+
|
|
90
|
+
return { repo: repoDir, remote: remoteDir };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createTestRepoSimple(): string {
|
|
94
|
+
const { repo } = createTestRepo();
|
|
95
|
+
return repo;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cleanupTestRepo(path: string): void {
|
|
99
|
+
try {
|
|
100
|
+
rmSync(path, { recursive: true, force: true });
|
|
101
|
+
} catch {
|
|
102
|
+
// Ignore cleanup errors
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('git-preamble', () => {
|
|
107
|
+
let testRepo: string;
|
|
108
|
+
let baseDir: string;
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
const { repo } = createTestRepo();
|
|
112
|
+
testRepo = repo;
|
|
113
|
+
// baseDir is the parent of repo
|
|
114
|
+
baseDir = join(testRepo, '..');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
cleanupTestRepo(baseDir);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('succeeds on clean main branch', () => {
|
|
122
|
+
const result = runScript('git-preamble', [], { codex_repo: testRepo });
|
|
123
|
+
|
|
124
|
+
expect(result.success).toBe(true);
|
|
125
|
+
expect(result.branch).toBe('main');
|
|
126
|
+
expect(result.stashed).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns to main from feature branch', () => {
|
|
130
|
+
// Create and checkout a feature branch
|
|
131
|
+
git(testRepo, 'checkout', '-b', 'feature/test');
|
|
132
|
+
|
|
133
|
+
const result = runScript('git-preamble', [], { codex_repo: testRepo });
|
|
134
|
+
|
|
135
|
+
expect(result.success).toBe(true);
|
|
136
|
+
expect(result.branch).toBe('main');
|
|
137
|
+
|
|
138
|
+
// Verify we're actually on main
|
|
139
|
+
const currentBranch = git(testRepo, 'branch', '--show-current');
|
|
140
|
+
expect(currentBranch).toBe('main');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('stashes uncommitted changes', () => {
|
|
144
|
+
// Create uncommitted changes
|
|
145
|
+
writeFileSync(join(testRepo, 'dirty.txt'), 'uncommitted');
|
|
146
|
+
git(testRepo, 'add', 'dirty.txt');
|
|
147
|
+
|
|
148
|
+
const result = runScript('git-preamble', [], { codex_repo: testRepo });
|
|
149
|
+
|
|
150
|
+
expect(result.success).toBe(true);
|
|
151
|
+
expect(result.stashed).toBe(true);
|
|
152
|
+
|
|
153
|
+
// Verify the file is no longer in working directory
|
|
154
|
+
const status = git(testRepo, 'status', '--porcelain');
|
|
155
|
+
expect(status).toBe('');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('fails with missing repo', () => {
|
|
159
|
+
const result = runScript('git-preamble', [], { codex_repo: '/nonexistent/path' });
|
|
160
|
+
|
|
161
|
+
expect(result.success).toBe(false);
|
|
162
|
+
expect(result.error).toContain('not found');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('aborts stuck rebase', () => {
|
|
166
|
+
// Create a situation with a stuck rebase (simulate by creating the file)
|
|
167
|
+
mkdirSync(join(testRepo, '.git', 'rebase-merge'), { recursive: true });
|
|
168
|
+
writeFileSync(join(testRepo, '.git', 'rebase-merge', 'git-rebase-todo'), '');
|
|
169
|
+
|
|
170
|
+
const result = runScript('git-preamble', [], { codex_repo: testRepo });
|
|
171
|
+
|
|
172
|
+
// Should succeed after aborting the rebase
|
|
173
|
+
expect(result.success).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('git-start-write', () => {
|
|
178
|
+
let testRepo: string;
|
|
179
|
+
let baseDir: string;
|
|
180
|
+
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
const { repo } = createTestRepo();
|
|
183
|
+
testRepo = repo;
|
|
184
|
+
baseDir = join(testRepo, '..');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
afterEach(() => {
|
|
188
|
+
cleanupTestRepo(baseDir);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('creates a new branch from main', () => {
|
|
192
|
+
const result = runScript('git-start-write', ['--branch', 'codex/test-branch'], {
|
|
193
|
+
codex_repo: testRepo,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(result.success).toBe(true);
|
|
197
|
+
expect(result.branch).toBe('codex/test-branch');
|
|
198
|
+
expect(result.from).toBe('main');
|
|
199
|
+
|
|
200
|
+
// Verify we're on the new branch
|
|
201
|
+
const currentBranch = git(testRepo, 'branch', '--show-current');
|
|
202
|
+
expect(currentBranch).toBe('codex/test-branch');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('resets existing branch to main', () => {
|
|
206
|
+
// Create branch with a commit
|
|
207
|
+
git(testRepo, 'checkout', '-b', 'codex/existing');
|
|
208
|
+
writeFileSync(join(testRepo, 'old-file.txt'), 'old content');
|
|
209
|
+
git(testRepo, 'add', '.');
|
|
210
|
+
git(testRepo, 'commit', '-m', 'Old commit');
|
|
211
|
+
git(testRepo, 'checkout', 'main');
|
|
212
|
+
|
|
213
|
+
// Start write should reset it
|
|
214
|
+
const result = runScript('git-start-write', ['--branch', 'codex/existing'], {
|
|
215
|
+
codex_repo: testRepo,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(result.success).toBe(true);
|
|
219
|
+
|
|
220
|
+
// The old file should not exist (branch was reset)
|
|
221
|
+
expect(existsSync(join(testRepo, 'old-file.txt'))).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('fails without branch argument', () => {
|
|
225
|
+
const result = runScript('git-start-write', [], { codex_repo: testRepo });
|
|
226
|
+
|
|
227
|
+
expect(result.success).toBe(false);
|
|
228
|
+
expect(result.error).toContain('--branch');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('runs preamble first (returns from feature branch)', () => {
|
|
232
|
+
// Start on a different branch
|
|
233
|
+
git(testRepo, 'checkout', '-b', 'feature/other');
|
|
234
|
+
|
|
235
|
+
const result = runScript('git-start-write', ['--branch', 'codex/new'], {
|
|
236
|
+
codex_repo: testRepo,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(result.success).toBe(true);
|
|
240
|
+
expect(result.branch).toBe('codex/new');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('git-finish-write', () => {
|
|
245
|
+
let testRepo: string;
|
|
246
|
+
let baseDir: string;
|
|
247
|
+
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
const { repo } = createTestRepo();
|
|
250
|
+
testRepo = repo;
|
|
251
|
+
baseDir = join(testRepo, '..');
|
|
252
|
+
// Start on a feature branch (simulating after git-start-write)
|
|
253
|
+
git(testRepo, 'checkout', '-b', 'codex/test-feature');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
afterEach(() => {
|
|
257
|
+
cleanupTestRepo(baseDir);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('commits changes and returns to main', () => {
|
|
261
|
+
// Create a change
|
|
262
|
+
writeFileSync(join(testRepo, 'new-file.md'), '# New Content\n');
|
|
263
|
+
|
|
264
|
+
const result = runScript(
|
|
265
|
+
'git-finish-write',
|
|
266
|
+
['--message', 'test: add new file', '--pr-title', 'Test PR', '--pr-body', 'Test body'],
|
|
267
|
+
{ codex_repo: testRepo }
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Note: PR creation will fail (no remote), but commit should succeed
|
|
271
|
+
// and we should return to main
|
|
272
|
+
const currentBranch = git(testRepo, 'branch', '--show-current');
|
|
273
|
+
expect(currentBranch).toBe('main');
|
|
274
|
+
|
|
275
|
+
// Verify the commit exists on the feature branch
|
|
276
|
+
const log = git(testRepo, 'log', 'codex/test-feature', '--oneline', '-1');
|
|
277
|
+
expect(log).toContain('test: add new file');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('fails when on main branch', () => {
|
|
281
|
+
git(testRepo, 'checkout', 'main');
|
|
282
|
+
|
|
283
|
+
const result = runScript(
|
|
284
|
+
'git-finish-write',
|
|
285
|
+
['--message', 'bad commit', '--pr-title', 'Bad PR'],
|
|
286
|
+
{ codex_repo: testRepo }
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
expect(result.success).toBe(false);
|
|
290
|
+
expect(result.error).toContain('Cannot commit directly to main');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('fails with no changes to commit', () => {
|
|
294
|
+
const result = runScript(
|
|
295
|
+
'git-finish-write',
|
|
296
|
+
['--message', 'empty commit', '--pr-title', 'Empty PR'],
|
|
297
|
+
{ codex_repo: testRepo }
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
expect(result.success).toBe(false);
|
|
301
|
+
expect(result.error).toContain('No changes to commit');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('fails without required arguments', () => {
|
|
305
|
+
const noMessage = runScript('git-finish-write', ['--pr-title', 'Test'], {
|
|
306
|
+
codex_repo: testRepo,
|
|
307
|
+
});
|
|
308
|
+
expect(noMessage.success).toBe(false);
|
|
309
|
+
expect(noMessage.error).toContain('--message');
|
|
310
|
+
|
|
311
|
+
const noTitle = runScript('git-finish-write', ['--message', 'Test'], {
|
|
312
|
+
codex_repo: testRepo,
|
|
313
|
+
});
|
|
314
|
+
expect(noTitle.success).toBe(false);
|
|
315
|
+
expect(noTitle.error).toContain('--pr-title');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('full workflow', () => {
|
|
320
|
+
let testRepo: string;
|
|
321
|
+
let baseDir: string;
|
|
322
|
+
|
|
323
|
+
beforeEach(() => {
|
|
324
|
+
const { repo } = createTestRepo();
|
|
325
|
+
testRepo = repo;
|
|
326
|
+
baseDir = join(testRepo, '..');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
afterEach(() => {
|
|
330
|
+
cleanupTestRepo(baseDir);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('preamble -> start-write -> make changes -> finish-write', () => {
|
|
334
|
+
// 1. Preamble
|
|
335
|
+
const preamble = runScript('git-preamble', [], { codex_repo: testRepo });
|
|
336
|
+
expect(preamble.success).toBe(true);
|
|
337
|
+
|
|
338
|
+
// 2. Start write
|
|
339
|
+
const start = runScript('git-start-write', ['--branch', 'codex/full-test'], {
|
|
340
|
+
codex_repo: testRepo,
|
|
341
|
+
});
|
|
342
|
+
expect(start.success).toBe(true);
|
|
343
|
+
expect(start.branch).toBe('codex/full-test');
|
|
344
|
+
|
|
345
|
+
// 3. Make changes (create directory first)
|
|
346
|
+
mkdirSync(join(testRepo, 'topics'), { recursive: true });
|
|
347
|
+
writeFileSync(join(testRepo, 'topics', 'test-topic.md'), '# Test Topic\n');
|
|
348
|
+
|
|
349
|
+
// 4. Finish write (PR will fail but commit should work)
|
|
350
|
+
const finish = runScript(
|
|
351
|
+
'git-finish-write',
|
|
352
|
+
['--message', 'topic: add test-topic', '--pr-title', 'Topic: test-topic'],
|
|
353
|
+
{ codex_repo: testRepo }
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
// Should return to main regardless of PR status
|
|
357
|
+
const currentBranch = git(testRepo, 'branch', '--show-current');
|
|
358
|
+
expect(currentBranch).toBe('main');
|
|
359
|
+
|
|
360
|
+
// Verify commit exists on feature branch
|
|
361
|
+
const commits = git(testRepo, 'log', 'codex/full-test', '--oneline');
|
|
362
|
+
expect(commits).toContain('topic: add test-topic');
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* codex git-start-write
|
|
4
|
+
*
|
|
5
|
+
* Prepares the codex repo for a write operation by running preamble
|
|
6
|
+
* and creating a new branch.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* droid config codex | droid exec droid-codex git-start-write --config - --branch codex/new-feature
|
|
10
|
+
*
|
|
11
|
+
* Options:
|
|
12
|
+
* --config <json> Config with codex_repo path (required)
|
|
13
|
+
* --branch <name> Branch name to create (required)
|
|
14
|
+
*
|
|
15
|
+
* What it does:
|
|
16
|
+
* 1. Run git-preamble (ensure clean main)
|
|
17
|
+
* 2. Create and checkout the new branch
|
|
18
|
+
*
|
|
19
|
+
* Output (JSON):
|
|
20
|
+
* { "success": true, "branch": "codex/new-feature", "from": "main" }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execSync } from 'child_process';
|
|
24
|
+
import { readFileSync, existsSync } from 'fs';
|
|
25
|
+
|
|
26
|
+
interface Config {
|
|
27
|
+
codex_repo: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Result {
|
|
31
|
+
success: boolean;
|
|
32
|
+
branch?: string;
|
|
33
|
+
from?: string;
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseArgs(args: string[]): { config: Config | null; branch: string | null } {
|
|
38
|
+
let config: Config | null = null;
|
|
39
|
+
let branch: string | null = null;
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < args.length; i++) {
|
|
42
|
+
const arg = args[i];
|
|
43
|
+
if (arg === '--config' && args[i + 1]) {
|
|
44
|
+
const configArg = args[++i];
|
|
45
|
+
if (configArg === '-') {
|
|
46
|
+
const stdin = readFileSync(0, 'utf-8').trim();
|
|
47
|
+
config = JSON.parse(stdin) as Config;
|
|
48
|
+
} else {
|
|
49
|
+
config = JSON.parse(configArg) as Config;
|
|
50
|
+
}
|
|
51
|
+
} else if (arg === '--branch' && args[i + 1]) {
|
|
52
|
+
branch = args[++i];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { config, branch };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function expandPath(p: string): string {
|
|
60
|
+
if (p.startsWith('~/')) {
|
|
61
|
+
return p.replace('~', process.env.HOME || '');
|
|
62
|
+
}
|
|
63
|
+
return p;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function run(cmd: string, cwd: string): { ok: boolean; output: string } {
|
|
67
|
+
try {
|
|
68
|
+
const output = execSync(cmd, {
|
|
69
|
+
cwd,
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
});
|
|
73
|
+
return { ok: true, output: output.trim() };
|
|
74
|
+
} catch (err: unknown) {
|
|
75
|
+
const error = err as { stderr?: string; message?: string };
|
|
76
|
+
return { ok: false, output: error.stderr || error.message || 'Unknown error' };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function gitStartWrite(repoPath: string, branchName: string): Result {
|
|
81
|
+
const cwd = expandPath(repoPath);
|
|
82
|
+
|
|
83
|
+
// Verify repo exists
|
|
84
|
+
if (!existsSync(cwd)) {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: `Codex repo not found at ${cwd}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 1. Run preamble steps inline (ensure clean main)
|
|
92
|
+
run('git merge --abort', cwd);
|
|
93
|
+
run('git rebase --abort', cwd);
|
|
94
|
+
run('git cherry-pick --abort', cwd);
|
|
95
|
+
|
|
96
|
+
const checkout = run('git checkout main', cwd);
|
|
97
|
+
if (!checkout.ok && !checkout.output.includes('Already on')) {
|
|
98
|
+
return { success: false, error: `Failed to checkout main: ${checkout.output}` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Stash uncommitted changes
|
|
102
|
+
const statusCheck = run('git status --porcelain', cwd);
|
|
103
|
+
if (statusCheck.ok && statusCheck.output.length > 0) {
|
|
104
|
+
run('git stash push -m "codex-auto-stash"', cwd);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Pull latest
|
|
108
|
+
const pull = run('git pull --rebase', cwd);
|
|
109
|
+
if (!pull.ok) {
|
|
110
|
+
run('git rebase --abort', cwd);
|
|
111
|
+
const retryPull = run('git pull --rebase', cwd);
|
|
112
|
+
if (!retryPull.ok) {
|
|
113
|
+
return { success: false, error: `Failed to pull latest: ${retryPull.output}` };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Create and checkout new branch
|
|
118
|
+
// First check if branch already exists (locally or remotely)
|
|
119
|
+
const branchExists = run(`git show-ref --verify --quiet refs/heads/${branchName}`, cwd);
|
|
120
|
+
if (branchExists.ok) {
|
|
121
|
+
// Branch exists, check it out and reset to main
|
|
122
|
+
run(`git checkout ${branchName}`, cwd);
|
|
123
|
+
run('git reset --hard main', cwd);
|
|
124
|
+
} else {
|
|
125
|
+
// Create new branch
|
|
126
|
+
const createBranch = run(`git checkout -b ${branchName}`, cwd);
|
|
127
|
+
if (!createBranch.ok) {
|
|
128
|
+
return { success: false, error: `Failed to create branch: ${createBranch.output}` };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
success: true,
|
|
134
|
+
branch: branchName,
|
|
135
|
+
from: 'main',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Main
|
|
140
|
+
const args = process.argv.slice(2);
|
|
141
|
+
const { config, branch } = parseArgs(args);
|
|
142
|
+
|
|
143
|
+
if (!config) {
|
|
144
|
+
console.log(JSON.stringify({
|
|
145
|
+
success: false,
|
|
146
|
+
error: 'Missing --config. Usage: droid config codex | droid exec droid-codex git-start-write --config - --branch <name>',
|
|
147
|
+
}));
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!config.codex_repo) {
|
|
152
|
+
console.log(JSON.stringify({
|
|
153
|
+
success: false,
|
|
154
|
+
error: 'Missing codex_repo in config',
|
|
155
|
+
}));
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!branch) {
|
|
160
|
+
console.log(JSON.stringify({
|
|
161
|
+
success: false,
|
|
162
|
+
error: 'Missing --branch argument',
|
|
163
|
+
}));
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result = gitStartWrite(config.codex_repo, branch);
|
|
168
|
+
console.log(JSON.stringify(result, null, 2));
|
|
169
|
+
|
|
170
|
+
if (!result.success) {
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|