@jakobm/peacekeeper 0.0.0-stage → 0.12.1
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/LICENSE +21 -0
- package/README.md +382 -2
- package/bin/peacekeeper.js +59 -0
- package/package.json +37 -4
- package/scripts/check-project-requirements.txt +326 -0
- package/scripts/check-project.js +235 -0
- package/scripts/check-test-receipt.js +48 -0
- package/scripts/checkpoint-codex-changes.js +236 -0
- package/scripts/checkpoint-codex-session-start.js +37 -0
- package/scripts/checkpoint-codex-stop.js +99 -0
- package/scripts/checkpoint-instructions.js +19 -0
- package/scripts/cleanup-ended-session.js +317 -0
- package/scripts/cleanup-merged-branches.js +382 -0
- package/scripts/install-hooks.js +155 -0
- package/scripts/log-script-invocation.js +80 -0
- package/scripts/project-config.js +99 -0
- package/scripts/queue-merged-worktree-cleanup.js +58 -0
- package/scripts/receipt-state.js +81 -0
- package/scripts/record-test-receipt.js +35 -0
- package/scripts/release-core.js +527 -0
- package/scripts/release-protection.js +67 -0
- package/scripts/release.js +18 -0
- package/scripts/remove-empty-directories.js +163 -0
- package/scripts/run-tests.js +56 -0
- package/scripts/verify-release-core.js +39 -0
- package/scripts/verify-release-protection.js +19 -0
- package/scripts/verify-release-version.js +14 -0
- package/scripts/verify-release.js +26 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Commit the current Codex worktree and push to configured remotes.
|
|
5
|
+
*
|
|
6
|
+
* Explicit checkpoint remotes also receive new branches. Without that setting,
|
|
7
|
+
* only an existing branch on the configured remote is pushed. Failures are recorded inside
|
|
8
|
+
* the worktree's Git metadata so the Stop hook can keep them visible.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { spawnSync } = require('node:child_process');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { isPrimaryWorktree } = require('./log-script-invocation.js');
|
|
15
|
+
const { projectConfig } = require('./project-config');
|
|
16
|
+
|
|
17
|
+
function runGit(args, options = {}) {
|
|
18
|
+
const result = spawnSync('git', args, {
|
|
19
|
+
cwd: options.cwd,
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (result.status !== 0) {
|
|
25
|
+
const detail = result.stderr?.trim() || result.stdout?.trim();
|
|
26
|
+
throw new Error(detail || `git ${args.join(' ')} failed`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return result.stdout?.trim() || '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tryGit(args, cwd) {
|
|
33
|
+
return spawnSync('git', args, {
|
|
34
|
+
cwd,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function markerPath(repositoryRoot) {
|
|
41
|
+
const gitDirectory = runGit([
|
|
42
|
+
'rev-parse',
|
|
43
|
+
'--path-format=absolute',
|
|
44
|
+
'--git-dir',
|
|
45
|
+
], { cwd: repositoryRoot });
|
|
46
|
+
|
|
47
|
+
return path.join(gitDirectory, 'codex-checkpoint-error.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeFailureMarker(repositoryRoot, error, branch) {
|
|
51
|
+
const marker = markerPath(repositoryRoot);
|
|
52
|
+
fs.writeFileSync(marker, `${JSON.stringify({
|
|
53
|
+
branch: branch || null,
|
|
54
|
+
error: error.message,
|
|
55
|
+
timestamp: new Date().toISOString(),
|
|
56
|
+
}, null, 2)}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clearFailureMarker(repositoryRoot) {
|
|
60
|
+
fs.rmSync(markerPath(repositoryRoot), { force: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function remoteBranchExists(repositoryRoot, branch) {
|
|
64
|
+
const { remote } = projectConfig(repositoryRoot).git;
|
|
65
|
+
const result = tryGit([
|
|
66
|
+
'ls-remote',
|
|
67
|
+
'--exit-code',
|
|
68
|
+
'--heads',
|
|
69
|
+
remote,
|
|
70
|
+
`refs/heads/${branch}`,
|
|
71
|
+
], repositoryRoot);
|
|
72
|
+
|
|
73
|
+
if (result.status === 0) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (result.status === 2) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const detail = result.stderr?.trim() || result.stdout?.trim();
|
|
81
|
+
throw new Error(detail || `could not inspect branches on ${remote}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateMessage(subject, bodyParagraphs) {
|
|
85
|
+
if (typeof subject !== 'string' || subject.trim() === '') {
|
|
86
|
+
throw new Error('a non-empty commit subject is required');
|
|
87
|
+
}
|
|
88
|
+
if (subject.includes('\n')) {
|
|
89
|
+
throw new Error('the commit subject must be a single line');
|
|
90
|
+
}
|
|
91
|
+
if (
|
|
92
|
+
!Array.isArray(bodyParagraphs)
|
|
93
|
+
|| bodyParagraphs.length === 0
|
|
94
|
+
|| bodyParagraphs.some((paragraph) => paragraph.trim() === '')
|
|
95
|
+
) {
|
|
96
|
+
throw new Error('at least one non-empty commit body paragraph is required');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
bodyParagraphs: bodyParagraphs.map((paragraph) => paragraph.trim()),
|
|
101
|
+
subject: subject.trim(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseArguments(args) {
|
|
106
|
+
let subject = null;
|
|
107
|
+
const bodyParagraphs = [];
|
|
108
|
+
|
|
109
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
110
|
+
const argument = args[index];
|
|
111
|
+
const value = args[index + 1];
|
|
112
|
+
|
|
113
|
+
if (argument === '--subject' && value !== undefined) {
|
|
114
|
+
if (subject !== null) {
|
|
115
|
+
throw new Error('--subject may only be provided once');
|
|
116
|
+
}
|
|
117
|
+
subject = value;
|
|
118
|
+
index += 1;
|
|
119
|
+
} else if (argument === '--body' && value !== undefined) {
|
|
120
|
+
bodyParagraphs.push(value);
|
|
121
|
+
index += 1;
|
|
122
|
+
} else {
|
|
123
|
+
throw new Error(`unknown or incomplete argument: ${argument}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return validateMessage(subject, bodyParagraphs);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function checkpoint(repositoryRoot, message, log = console.log) {
|
|
131
|
+
if (isPrimaryWorktree(repositoryRoot)) {
|
|
132
|
+
throw new Error('refusing to checkpoint the primary Git worktree');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let branch = null;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const { subject, bodyParagraphs } = validateMessage(
|
|
139
|
+
message?.subject,
|
|
140
|
+
message?.bodyParagraphs,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
branch = runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], {
|
|
144
|
+
cwd: repositoryRoot,
|
|
145
|
+
});
|
|
146
|
+
const { defaultBranch, remote, checkpointPushRemotes } = projectConfig(repositoryRoot).git;
|
|
147
|
+
if (branch === defaultBranch) {
|
|
148
|
+
throw new Error(`refusing to checkpoint changes directly on ${defaultBranch}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const conflicts = runGit(['diff', '--name-only', '--diff-filter=U'], {
|
|
152
|
+
cwd: repositoryRoot,
|
|
153
|
+
});
|
|
154
|
+
if (conflicts !== '') {
|
|
155
|
+
throw new Error(`resolve merge conflicts before checkpointing:\n${conflicts}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const status = runGit(['status', '--porcelain', '--untracked-files=all'], {
|
|
159
|
+
cwd: repositoryRoot,
|
|
160
|
+
});
|
|
161
|
+
const previousFailureExists = fs.existsSync(markerPath(repositoryRoot));
|
|
162
|
+
|
|
163
|
+
if (status !== '') {
|
|
164
|
+
runGit(['add', '--all'], { cwd: repositoryRoot });
|
|
165
|
+
const commitArguments = ['commit', '--message', subject];
|
|
166
|
+
for (const paragraph of bodyParagraphs) {
|
|
167
|
+
commitArguments.push('--message', paragraph);
|
|
168
|
+
}
|
|
169
|
+
runGit(commitArguments, {
|
|
170
|
+
cwd: repositoryRoot,
|
|
171
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
172
|
+
});
|
|
173
|
+
log(`Committed ${branch}: ${subject}`);
|
|
174
|
+
} else if (!previousFailureExists) {
|
|
175
|
+
log('No changes to checkpoint.');
|
|
176
|
+
return { committed: false, pushed: false };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const pushRemotes = checkpointPushRemotes ?? (
|
|
180
|
+
remoteBranchExists(repositoryRoot, branch) ? [remote] : []
|
|
181
|
+
);
|
|
182
|
+
if (pushRemotes.length === 0) {
|
|
183
|
+
clearFailureMarker(repositoryRoot);
|
|
184
|
+
log(checkpointPushRemotes === undefined
|
|
185
|
+
? `Kept ${branch} local because ${remote}/${branch} does not exist.`
|
|
186
|
+
: `Kept ${branch} local because checkpoint push is disabled.`);
|
|
187
|
+
return { committed: status !== '', pushed: false };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const pushRemote of pushRemotes) {
|
|
191
|
+
const configuredRemotes = runGit(['remote'], { cwd: repositoryRoot }).split('\n');
|
|
192
|
+
if (!configuredRemotes.includes(pushRemote)) {
|
|
193
|
+
throw new Error(`checkpoint remote ${pushRemote} is not configured in Git`);
|
|
194
|
+
}
|
|
195
|
+
runGit([
|
|
196
|
+
'push',
|
|
197
|
+
...(remote === pushRemote ? ['--set-upstream'] : []),
|
|
198
|
+
pushRemote,
|
|
199
|
+
`HEAD:refs/heads/${branch}`,
|
|
200
|
+
], { cwd: repositoryRoot });
|
|
201
|
+
log(`Pushed ${branch} to ${pushRemote}/${branch}.`);
|
|
202
|
+
}
|
|
203
|
+
clearFailureMarker(repositoryRoot);
|
|
204
|
+
return { committed: status !== '', pushed: true };
|
|
205
|
+
} catch (error) {
|
|
206
|
+
try {
|
|
207
|
+
writeFailureMarker(repositoryRoot, error, branch);
|
|
208
|
+
} catch {
|
|
209
|
+
// Preserve the original Git error when recording it also fails.
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function main() {
|
|
216
|
+
const repositoryRoot = runGit(['rev-parse', '--show-toplevel']);
|
|
217
|
+
const message = parseArguments(process.argv.slice(2));
|
|
218
|
+
checkpoint(repositoryRoot, message);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (require.main === module) {
|
|
222
|
+
try {
|
|
223
|
+
main();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
console.error(`Error: ${error.message}`);
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
checkpoint,
|
|
232
|
+
markerPath,
|
|
233
|
+
parseArguments,
|
|
234
|
+
remoteBranchExists,
|
|
235
|
+
validateMessage,
|
|
236
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { checkpointInstructions } = require('./checkpoint-instructions');
|
|
5
|
+
const { projectConfig } = require('./project-config');
|
|
6
|
+
const { isPrimaryWorktree, logScriptInvocation } = require('./log-script-invocation');
|
|
7
|
+
|
|
8
|
+
function buildResponse(event) {
|
|
9
|
+
if (event?.hook_event_name !== 'SessionStart' || typeof event.cwd !== 'string') {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
if (isPrimaryWorktree(event.cwd)) return {};
|
|
13
|
+
return {
|
|
14
|
+
hookSpecificOutput: {
|
|
15
|
+
hookEventName: 'SessionStart',
|
|
16
|
+
additionalContext: checkpointInstructions(projectConfig(event.cwd)),
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function main() {
|
|
22
|
+
logScriptInvocation();
|
|
23
|
+
const input = fs.readFileSync(0, 'utf8').trim();
|
|
24
|
+
process.stdout.write(`${JSON.stringify(buildResponse(input === '' ? null : JSON.parse(input)))}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (require.main === module) {
|
|
28
|
+
try {
|
|
29
|
+
main();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
process.stdout.write(`${JSON.stringify({
|
|
32
|
+
systemMessage: `Codex checkpoint instructions failed: ${error.message}`,
|
|
33
|
+
})}\n`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { buildResponse };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Continue a stopping Codex turn once when its worktree needs a checkpoint.
|
|
5
|
+
*
|
|
6
|
+
* The continuation lets the agent use its task context to choose a meaningful
|
|
7
|
+
* English commit message instead of forcing the hook to invent one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { spawnSync } = require('node:child_process');
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const { markerPath } = require('./checkpoint-codex-changes.js');
|
|
13
|
+
const { projectConfig } = require('./project-config');
|
|
14
|
+
const { checkpointInstructions } = require('./checkpoint-instructions');
|
|
15
|
+
const {
|
|
16
|
+
isPrimaryWorktree,
|
|
17
|
+
logScriptInvocation,
|
|
18
|
+
} = require('./log-script-invocation.js');
|
|
19
|
+
|
|
20
|
+
function runGit(args, cwd) {
|
|
21
|
+
const result = spawnSync('git', args, {
|
|
22
|
+
cwd,
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (result.status !== 0) {
|
|
28
|
+
const detail = result.stderr?.trim() || result.stdout?.trim();
|
|
29
|
+
throw new Error(detail || `git ${args.join(' ')} failed`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return result.stdout.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readFailure(repositoryRoot) {
|
|
36
|
+
const file = markerPath(repositoryRoot);
|
|
37
|
+
if (!fs.existsSync(file)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
43
|
+
} catch {
|
|
44
|
+
return { error: 'the previous checkpoint failed' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildResponse(event) {
|
|
49
|
+
if (event?.hook_event_name !== 'Stop' || typeof event.cwd !== 'string') {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
if (isPrimaryWorktree(event.cwd)) {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
const config = projectConfig(event.cwd);
|
|
56
|
+
|
|
57
|
+
const status = runGit(['status', '--porcelain', '--untracked-files=all'], event.cwd);
|
|
58
|
+
const failure = readFailure(event.cwd);
|
|
59
|
+
|
|
60
|
+
if (status === '' && !failure) {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (event.stop_hook_active) {
|
|
65
|
+
const problem = failure?.error || 'uncommitted changes remain';
|
|
66
|
+
return {
|
|
67
|
+
systemMessage: `Codex checkpoint incomplete: ${problem}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const failureInstruction = failure
|
|
72
|
+
? ` The previous checkpoint failed with: ${failure.error}. Resolve that failure first.`
|
|
73
|
+
: '';
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
decision: 'block',
|
|
77
|
+
reason: checkpointInstructions(config)
|
|
78
|
+
+ failureInstruction,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function main() {
|
|
83
|
+
logScriptInvocation();
|
|
84
|
+
const input = fs.readFileSync(0, 'utf8').trim();
|
|
85
|
+
const event = input === '' ? null : JSON.parse(input);
|
|
86
|
+
process.stdout.write(`${JSON.stringify(buildResponse(event))}\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (require.main === module) {
|
|
90
|
+
try {
|
|
91
|
+
main();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
process.stdout.write(`${JSON.stringify({
|
|
94
|
+
systemMessage: `Codex checkpoint check failed: ${error.message}`,
|
|
95
|
+
})}\n`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { buildResponse };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Shared policy delivered before work starts and when Stop requests a retry. */
|
|
2
|
+
function checkpointInstructions(config) {
|
|
3
|
+
return 'This checkpoint workflow applies only in linked Git worktrees, never in the primary checkout. '
|
|
4
|
+
+ 'Follow any explicit user instruction not to commit or push. If such an instruction conflicts '
|
|
5
|
+
+ 'with this workflow, do not run the checkpoint command; explain that the checkpoint was skipped '
|
|
6
|
+
+ 'at the user\'s request, even if a Stop hook asks for it. '
|
|
7
|
+
+ 'Before the final response for a task that changed repository files, review the completed diff '
|
|
8
|
+
+ 'and checkpoint the work. If HEAD is detached or the worktree is on the configured default branch '
|
|
9
|
+
+ `(${config.git.defaultBranch}), first create and switch to a descriptive task branch named with the ${config.git.branchPrefix} prefix. `
|
|
10
|
+
+ 'Write an English commit message with a concise, descriptive single-line subject and a body '
|
|
11
|
+
+ 'separated from it by a blank line. The English body should explain the change using between '
|
|
12
|
+
+ 'one and ten sentences. Run `npm exec --no -- peacekeeper checkpoint-codex-changes '
|
|
13
|
+
+ '--subject "<subject>" --body "<body paragraph>"`; repeat `--body` for additional paragraphs when useful. '
|
|
14
|
+
+ 'The command stages all changes, so do not include unrelated changes. '
|
|
15
|
+
+ 'Push behavior follows the project configuration. Unless an explicit user instruction disables '
|
|
16
|
+
+ 'this workflow, finish only after the checkpoint succeeds.';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { checkpointInstructions };
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Queue targeted cleanup for the branch used by a Codex session that ended.
|
|
5
|
+
*
|
|
6
|
+
* SessionEnd hooks have a maximum runtime of three seconds, so the hook process
|
|
7
|
+
* captures the worktree identity and launches a detached worker. The worker
|
|
8
|
+
* removes only that worktree and branch, and only when the captured branch tip
|
|
9
|
+
* is still fully merged into the configured remote default branch.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { projectConfig } = require('./project-config');
|
|
16
|
+
const {
|
|
17
|
+
applyPlan,
|
|
18
|
+
buildPlan,
|
|
19
|
+
listWorktrees,
|
|
20
|
+
} = require('./cleanup-merged-branches.js');
|
|
21
|
+
const {
|
|
22
|
+
isPrimaryWorktree,
|
|
23
|
+
logScriptInvocation,
|
|
24
|
+
sourceRepositoryRoot,
|
|
25
|
+
} = require('./log-script-invocation.js');
|
|
26
|
+
|
|
27
|
+
const LOCK_MAX_AGE_MS = 10 * 60 * 1000;
|
|
28
|
+
const LOCK_WAIT_MS = 30 * 1000;
|
|
29
|
+
|
|
30
|
+
function runGit(args, options = {}) {
|
|
31
|
+
const result = spawnSync('git', args, {
|
|
32
|
+
cwd: options.cwd,
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (result.status !== 0) {
|
|
38
|
+
const detail = result.stderr?.trim() || result.stdout?.trim();
|
|
39
|
+
throw new Error(detail || `git ${args.join(' ')} failed`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return result.stdout.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tryGit(args, cwd) {
|
|
46
|
+
const result = spawnSync('git', args, {
|
|
47
|
+
cwd,
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function samePath(first, second) {
|
|
56
|
+
try {
|
|
57
|
+
return fs.realpathSync(first) === fs.realpathSync(second);
|
|
58
|
+
} catch {
|
|
59
|
+
return path.resolve(first) === path.resolve(second);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function captureWorktreeTarget(cwd, sessionId = null) {
|
|
64
|
+
if (typeof cwd !== 'string' || isPrimaryWorktree(cwd)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const { defaultBranch } = projectConfig(cwd).git;
|
|
68
|
+
|
|
69
|
+
const branch = tryGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd);
|
|
70
|
+
if (!branch || branch === defaultBranch) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const objectId = runGit(['rev-parse', `refs/heads/${branch}`], { cwd });
|
|
75
|
+
const worktrees = listWorktrees(cwd);
|
|
76
|
+
const sessionWorktree = worktrees.find((item) => samePath(item.path, cwd));
|
|
77
|
+
|
|
78
|
+
if (!sessionWorktree || sessionWorktree.branch !== `refs/heads/${branch}`) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const commonGitDirectory = runGit([
|
|
83
|
+
'rev-parse',
|
|
84
|
+
'--path-format=absolute',
|
|
85
|
+
'--git-common-dir',
|
|
86
|
+
], { cwd });
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
branch,
|
|
90
|
+
commonGitDirectory,
|
|
91
|
+
objectId,
|
|
92
|
+
repositoryRoot: sourceRepositoryRoot(cwd),
|
|
93
|
+
sessionId,
|
|
94
|
+
worktreePath: sessionWorktree.path,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function captureTarget(event) {
|
|
99
|
+
if (event?.hook_event_name !== 'SessionEnd' || typeof event.cwd !== 'string') {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return captureWorktreeTarget(event.cwd, event.session_id || null);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fastForwardLocalDefaultBranch(repositoryRoot, log) {
|
|
106
|
+
const primaryRoot = sourceRepositoryRoot(repositoryRoot);
|
|
107
|
+
const { remote, defaultBranch } = projectConfig(primaryRoot).git;
|
|
108
|
+
const localRef = `refs/heads/${defaultBranch}`;
|
|
109
|
+
const remoteRef = `refs/remotes/${remote}/${defaultBranch}`;
|
|
110
|
+
const localTip = tryGit(['show-ref', '--hash', '--verify', localRef], primaryRoot);
|
|
111
|
+
if (!localTip) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const remoteTip = runGit(['rev-parse', remoteRef], {
|
|
116
|
+
cwd: primaryRoot,
|
|
117
|
+
});
|
|
118
|
+
if (localTip === remoteTip) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (tryGit(['merge-base', '--is-ancestor', localTip, remoteTip], primaryRoot) === null) {
|
|
122
|
+
log(`Keep ${defaultBranch}: it cannot fast-forward to ${remote}/${defaultBranch}.`);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const defaultWorktree = listWorktrees(primaryRoot)
|
|
127
|
+
.find((worktree) => worktree.branch === localRef);
|
|
128
|
+
if (defaultWorktree) {
|
|
129
|
+
const status = runGit(['status', '--porcelain', '--untracked-files=all'], {
|
|
130
|
+
cwd: defaultWorktree.path,
|
|
131
|
+
});
|
|
132
|
+
if (status !== '') {
|
|
133
|
+
log(`Keep ${defaultBranch}: its worktree has changes.`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
log(`Fast-forwarding ${defaultBranch} in its worktree to ${remote}/${defaultBranch}.`);
|
|
138
|
+
runGit(['merge', '--ff-only', remoteTip], { cwd: defaultWorktree.path });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
log(`Fast-forwarding ${defaultBranch} reference to ${remote}/${defaultBranch}.`);
|
|
143
|
+
runGit([
|
|
144
|
+
'update-ref',
|
|
145
|
+
'-m',
|
|
146
|
+
`fast-forward ${defaultBranch} to ${remote}/${defaultBranch}`,
|
|
147
|
+
localRef,
|
|
148
|
+
remoteTip,
|
|
149
|
+
localTip,
|
|
150
|
+
], { cwd: primaryRoot });
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function cleanupTarget(target, log = console.log) {
|
|
155
|
+
const { remote, defaultBranch } = projectConfig(target.repositoryRoot).git;
|
|
156
|
+
const branchRef = `refs/heads/${target.branch}`;
|
|
157
|
+
const currentObjectId = tryGit(['show-ref', '--hash', '--verify', branchRef], target.repositoryRoot);
|
|
158
|
+
|
|
159
|
+
if (currentObjectId !== target.objectId) {
|
|
160
|
+
log(`Skip ${target.branch}: the local branch changed after the session ended.`);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const currentWorktrees = listWorktrees(target.repositoryRoot);
|
|
165
|
+
const targetWorktree = currentWorktrees.find((item) => samePath(item.path, target.worktreePath));
|
|
166
|
+
|
|
167
|
+
if (targetWorktree) {
|
|
168
|
+
if (targetWorktree.branch !== branchRef) {
|
|
169
|
+
log(`Skip ${target.branch}: the worktree now uses a different branch.`);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
if (Object.hasOwn(targetWorktree, 'locked')) {
|
|
173
|
+
log(`Skip ${target.branch}: its worktree is locked.`);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const worktreeObjectId = tryGit(['rev-parse', 'HEAD'], target.worktreePath);
|
|
178
|
+
if (worktreeObjectId !== target.objectId) {
|
|
179
|
+
log(`Skip ${target.branch}: the worktree HEAD changed after the session ended.`);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const status = runGit(['status', '--porcelain', '--untracked-files=all'], {
|
|
184
|
+
cwd: target.worktreePath,
|
|
185
|
+
});
|
|
186
|
+
if (status !== '') {
|
|
187
|
+
log(`Skip ${target.branch}: its worktree has changes.`);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
log(`Fetching ${remote} before checking ${target.branch}.`);
|
|
193
|
+
runGit(['fetch', '--prune', remote], { cwd: target.repositoryRoot });
|
|
194
|
+
runGit(['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${defaultBranch}`], {
|
|
195
|
+
cwd: target.repositoryRoot,
|
|
196
|
+
});
|
|
197
|
+
fastForwardLocalDefaultBranch(target.repositoryRoot, log);
|
|
198
|
+
|
|
199
|
+
const item = buildPlan(target.repositoryRoot)
|
|
200
|
+
.find((candidate) => candidate.branch === target.branch);
|
|
201
|
+
|
|
202
|
+
if (!item?.local || item.localObjectId !== target.objectId) {
|
|
203
|
+
log(`Keep ${target.branch}: it is not fully merged into ${remote}/${defaultBranch}.`);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
if (item.skipReason) {
|
|
207
|
+
log(`Skip ${target.branch}: ${item.skipReason}`);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
item.worktrees.length > 0
|
|
212
|
+
&& !item.worktrees.every((worktree) => samePath(worktree.path, target.worktreePath))
|
|
213
|
+
) {
|
|
214
|
+
log(`Skip ${target.branch}: it is checked out in another worktree.`);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
applyPlan(target.repositoryRoot, [item], true, log);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sleep(milliseconds) {
|
|
223
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function acquireLock(lockPath) {
|
|
227
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
228
|
+
|
|
229
|
+
while (Date.now() < deadline) {
|
|
230
|
+
try {
|
|
231
|
+
fs.mkdirSync(lockPath);
|
|
232
|
+
return;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error.code !== 'EEXIST') {
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const age = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
240
|
+
if (age > LOCK_MAX_AGE_MS) {
|
|
241
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
} catch (statError) {
|
|
245
|
+
if (statError.code !== 'ENOENT') {
|
|
246
|
+
throw statError;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
sleep(200);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
throw new Error('timed out waiting for another session cleanup');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function runWorker(target) {
|
|
258
|
+
const lockPath = path.join(target.commonGitDirectory, 'cleanup-ended-session.lock');
|
|
259
|
+
acquireLock(lockPath);
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
console.log(`\nSession cleanup for ${target.branch}`);
|
|
263
|
+
cleanupTarget(target);
|
|
264
|
+
} finally {
|
|
265
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function launchWorker(target) {
|
|
270
|
+
const logPath = path.join(target.commonGitDirectory, 'cleanup-ended-session.log');
|
|
271
|
+
const logFile = fs.openSync(logPath, 'a');
|
|
272
|
+
const payload = Buffer.from(JSON.stringify(target)).toString('base64url');
|
|
273
|
+
const child = spawn(process.execPath, [__filename, '--worker', payload], {
|
|
274
|
+
cwd: target.repositoryRoot,
|
|
275
|
+
detached: true,
|
|
276
|
+
stdio: ['ignore', logFile, logFile],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
child.unref();
|
|
280
|
+
fs.closeSync(logFile);
|
|
281
|
+
return { logPath, pid: child.pid };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function readHookEvent() {
|
|
285
|
+
const input = fs.readFileSync(0, 'utf8').trim();
|
|
286
|
+
return input === '' ? null : JSON.parse(input);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function main() {
|
|
290
|
+
logScriptInvocation();
|
|
291
|
+
if (process.argv[2] === '--worker') {
|
|
292
|
+
const target = JSON.parse(Buffer.from(process.argv[3], 'base64url').toString('utf8'));
|
|
293
|
+
runWorker(target);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const target = captureTarget(readHookEvent());
|
|
298
|
+
if (target) {
|
|
299
|
+
launchWorker(target);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (require.main === module) {
|
|
304
|
+
try {
|
|
305
|
+
main();
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error(`Error: ${error.message}`);
|
|
308
|
+
process.exitCode = 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
captureTarget,
|
|
314
|
+
captureWorktreeTarget,
|
|
315
|
+
cleanupTarget,
|
|
316
|
+
launchWorker,
|
|
317
|
+
};
|