@susu-eng/trunk-sync 2.3.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/.claude-plugin/marketplace.json +26 -0
- package/.claude-plugin/plugin.json +11 -0
- package/README.md +86 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +44 -0
- package/dist/commands/config.d.ts +4 -0
- package/dist/commands/config.js +85 -0
- package/dist/commands/install.d.ts +1 -0
- package/dist/commands/install.js +66 -0
- package/dist/commands/seance.d.ts +1 -0
- package/dist/commands/seance.js +228 -0
- package/dist/lib/git.d.ts +14 -0
- package/dist/lib/git.js +71 -0
- package/dist/lib/hook-entry.d.ts +1 -0
- package/dist/lib/hook-entry.js +24 -0
- package/dist/lib/hook-execute.d.ts +15 -0
- package/dist/lib/hook-execute.js +311 -0
- package/dist/lib/hook-plan.d.ts +26 -0
- package/dist/lib/hook-plan.js +175 -0
- package/dist/lib/hook-types.d.ts +54 -0
- package/dist/lib/hook-types.js +1 -0
- package/hooks/hooks.json +16 -0
- package/package.json +41 -0
- package/rules/trunk-sync.md +29 -0
- package/scripts/trunk-sync.sh +4 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { HookInput, RepoState, HookPlan } from "./hook-types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Gather the current git repo state needed for planning.
|
|
4
|
+
* Runs git commands — this is the I/O boundary.
|
|
5
|
+
*/
|
|
6
|
+
export declare function gatherRepoState(input: HookInput): RepoState | null;
|
|
7
|
+
/**
|
|
8
|
+
* Execute a hook plan: stage files, commit, sync.
|
|
9
|
+
* Returns exit code and optional stderr for agent feedback.
|
|
10
|
+
*/
|
|
11
|
+
export declare function executePlan(plan: HookPlan, input: HookInput, state: RepoState): {
|
|
12
|
+
exitCode: number;
|
|
13
|
+
stderr?: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function findWorktreeForBranch(porcelainOutput: string, branch: string): string | null;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, mkdirSync, copyFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { readConfig } from "../commands/config.js";
|
|
6
|
+
import { HOOK_EXPLAINER } from "./hook-types.js";
|
|
7
|
+
import { extractTaskFromTranscript, buildCommitPlanWithTask } from "./hook-plan.js";
|
|
8
|
+
/**
|
|
9
|
+
* Gather the current git repo state needed for planning.
|
|
10
|
+
* Runs git commands — this is the I/O boundary.
|
|
11
|
+
*/
|
|
12
|
+
export function gatherRepoState(input) {
|
|
13
|
+
const filePath = input.tool_input.file_path ?? null;
|
|
14
|
+
let repoRoot;
|
|
15
|
+
try {
|
|
16
|
+
repoRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null; // not in a git repo
|
|
20
|
+
}
|
|
21
|
+
const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf-8" }).trim();
|
|
22
|
+
let insideRepo = true;
|
|
23
|
+
let gitignored = false;
|
|
24
|
+
let relPath = null;
|
|
25
|
+
if (filePath) {
|
|
26
|
+
// Resolve symlinks so /var/... matches /private/var/... on macOS
|
|
27
|
+
const resolvedFile = existsSync(filePath) ? realpathSync(filePath) : filePath;
|
|
28
|
+
insideRepo = resolvedFile.startsWith(repoRoot + "/");
|
|
29
|
+
if (insideRepo) {
|
|
30
|
+
relPath = resolvedFile.slice(repoRoot.length + 1);
|
|
31
|
+
try {
|
|
32
|
+
execSync(`git check-ignore -q -- "${filePath}"`, { stdio: "ignore" });
|
|
33
|
+
gitignored = true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
gitignored = false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
let hasRemote = true;
|
|
41
|
+
try {
|
|
42
|
+
execSync("git remote get-url origin", { stdio: "ignore" });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
hasRemote = false;
|
|
46
|
+
}
|
|
47
|
+
let targetBranch = "";
|
|
48
|
+
if (hasRemote) {
|
|
49
|
+
try {
|
|
50
|
+
const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
|
|
51
|
+
encoding: "utf-8",
|
|
52
|
+
}).trim();
|
|
53
|
+
targetBranch = ref.replace("refs/remotes/origin/", "");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
targetBranch = "main";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let currentBranch = "";
|
|
60
|
+
try {
|
|
61
|
+
currentBranch = execSync("git symbolic-ref --short HEAD", { encoding: "utf-8" }).trim();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// detached HEAD
|
|
65
|
+
}
|
|
66
|
+
const inMerge = existsSync(join(gitDir, "MERGE_HEAD"));
|
|
67
|
+
let hasStagedChanges = false;
|
|
68
|
+
try {
|
|
69
|
+
execSync("git diff --cached --quiet", { stdio: "ignore" });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
hasStagedChanges = true;
|
|
73
|
+
}
|
|
74
|
+
let deletedFiles = [];
|
|
75
|
+
if (!filePath) {
|
|
76
|
+
try {
|
|
77
|
+
const deleted = execSync(`git -C "${repoRoot}" ls-files --deleted`, {
|
|
78
|
+
encoding: "utf-8",
|
|
79
|
+
}).trim();
|
|
80
|
+
if (deleted)
|
|
81
|
+
deletedFiles = deleted.split("\n");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// ignore
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
repoRoot,
|
|
89
|
+
gitDir,
|
|
90
|
+
relPath,
|
|
91
|
+
insideRepo,
|
|
92
|
+
gitignored,
|
|
93
|
+
hasRemote,
|
|
94
|
+
targetBranch,
|
|
95
|
+
currentBranch,
|
|
96
|
+
inMerge,
|
|
97
|
+
hasStagedChanges,
|
|
98
|
+
deletedFiles,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Execute a hook plan: stage files, commit, sync.
|
|
103
|
+
* Returns exit code and optional stderr for agent feedback.
|
|
104
|
+
*/
|
|
105
|
+
export function executePlan(plan, input, state) {
|
|
106
|
+
if (plan.action === "skip")
|
|
107
|
+
return { exitCode: 0 };
|
|
108
|
+
if (plan.action === "commit-merge") {
|
|
109
|
+
// Stage the file if provided
|
|
110
|
+
const filePath = input.tool_input.file_path;
|
|
111
|
+
if (filePath) {
|
|
112
|
+
execSync(`git add -- "${filePath}"`);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
execSync(`git commit -m "${escapeForShell(plan.message)}"`);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
// Let git's exit code pass through (e.g. 128 for unresolved merge paths)
|
|
119
|
+
const code = getExitCode(e);
|
|
120
|
+
return { exitCode: code, stderr: getStdout(e) };
|
|
121
|
+
}
|
|
122
|
+
if (plan.sync)
|
|
123
|
+
return executeSync(plan.sync);
|
|
124
|
+
return { exitCode: 0 };
|
|
125
|
+
}
|
|
126
|
+
// commit-and-sync
|
|
127
|
+
const { commit, sync } = plan;
|
|
128
|
+
// Stage deletions
|
|
129
|
+
for (const file of commit.filesToRemove) {
|
|
130
|
+
try {
|
|
131
|
+
execSync(`git -C "${state.repoRoot}" rm --cached --quiet -- "${file}"`, {
|
|
132
|
+
stdio: "ignore",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// ignore
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Stage file edits
|
|
140
|
+
for (const file of commit.filesToStage) {
|
|
141
|
+
execSync(`git add -- "${file}"`);
|
|
142
|
+
}
|
|
143
|
+
// Check if there's anything staged (may have been a no-op)
|
|
144
|
+
try {
|
|
145
|
+
execSync("git diff --cached --quiet", { stdio: "ignore" });
|
|
146
|
+
return { exitCode: 0 }; // nothing to commit
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// has staged changes — continue
|
|
150
|
+
}
|
|
151
|
+
// Try to enrich commit message with task from transcript
|
|
152
|
+
let finalCommit = commit;
|
|
153
|
+
if (input.transcript_path) {
|
|
154
|
+
const expanded = input.transcript_path.replace(/^~/, homedir());
|
|
155
|
+
try {
|
|
156
|
+
const content = readFileSync(expanded, "utf-8");
|
|
157
|
+
const task = extractTaskFromTranscript(content);
|
|
158
|
+
if (task) {
|
|
159
|
+
finalCommit = buildCommitPlanWithTask(input, state, task);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// best-effort
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Commit
|
|
167
|
+
if (finalCommit.body) {
|
|
168
|
+
execSync(`git commit -m "${escapeForShell(finalCommit.subject)}" -m "${escapeForShell(finalCommit.body)}"`);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
execSync(`git commit -m "${escapeForShell(finalCommit.subject)}"`);
|
|
172
|
+
}
|
|
173
|
+
// Snapshot transcript into the commit (opt-in via config)
|
|
174
|
+
amendWithTranscriptSnapshot(input, state);
|
|
175
|
+
if (sync)
|
|
176
|
+
return executeSync(sync);
|
|
177
|
+
return { exitCode: 0 };
|
|
178
|
+
}
|
|
179
|
+
function amendWithTranscriptSnapshot(input, state) {
|
|
180
|
+
try {
|
|
181
|
+
const config = readConfig();
|
|
182
|
+
if (config.get("commit-transcripts") !== "true")
|
|
183
|
+
return;
|
|
184
|
+
if (!input.transcript_path || !input.session_id)
|
|
185
|
+
return;
|
|
186
|
+
const expanded = input.transcript_path.replace(/^~/, homedir());
|
|
187
|
+
if (!existsSync(expanded))
|
|
188
|
+
return;
|
|
189
|
+
const snapshotDir = join(state.repoRoot, ".transcripts");
|
|
190
|
+
mkdirSync(snapshotDir, { recursive: true });
|
|
191
|
+
const shortSession = input.session_id.slice(0, 8);
|
|
192
|
+
const epoch = Math.floor(Date.now() / 1000);
|
|
193
|
+
const snapshotName = `${shortSession}-${epoch}.jsonl`;
|
|
194
|
+
copyFileSync(expanded, join(snapshotDir, snapshotName));
|
|
195
|
+
execSync(`git add -- "${snapshotDir}"`, { cwd: state.repoRoot });
|
|
196
|
+
execSync(`git commit --amend --no-edit`, { cwd: state.repoRoot });
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// best-effort — don't fail the hook if snapshot fails
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function executeSync(sync) {
|
|
203
|
+
const { targetBranch, currentBranch } = sync;
|
|
204
|
+
// Pull from origin
|
|
205
|
+
try {
|
|
206
|
+
execSync(`git pull origin "${targetBranch}" --no-rebase 2>&1`, { encoding: "utf-8" });
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
return conflictExit(getStdout(e), targetBranch);
|
|
210
|
+
}
|
|
211
|
+
// Merge local target branch into worktree branch
|
|
212
|
+
if (currentBranch && currentBranch !== targetBranch) {
|
|
213
|
+
try {
|
|
214
|
+
execSync(`git merge "${targetBranch}" --no-edit 2>&1`, { encoding: "utf-8" });
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
return conflictExit(getStdout(e), targetBranch);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Push, retry once on failure
|
|
221
|
+
try {
|
|
222
|
+
execSync(`git push origin "HEAD:${targetBranch}" 2>&1`, { encoding: "utf-8" });
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Retry: pull then push
|
|
226
|
+
try {
|
|
227
|
+
execSync(`git pull origin "${targetBranch}" --no-rebase 2>&1`, { encoding: "utf-8" });
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
return conflictExit(getStdout(e), targetBranch);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
execSync(`git push origin "HEAD:${targetBranch}" 2>&1`, { encoding: "utf-8" });
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
return pushExit(getStdout(e), targetBranch);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Keep local target branch in sync
|
|
240
|
+
try {
|
|
241
|
+
execSync(`git fetch origin "${targetBranch}:${targetBranch}" 2>/dev/null`);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// If fetch fails (branch checked out), try ff-merge in the worktree
|
|
245
|
+
try {
|
|
246
|
+
const wtOutput = execSync(`git worktree list --porcelain`, { encoding: "utf-8" });
|
|
247
|
+
const mainWt = findWorktreeForBranch(wtOutput, targetBranch);
|
|
248
|
+
if (mainWt) {
|
|
249
|
+
try {
|
|
250
|
+
execSync(`git -C "${mainWt}" merge --ff-only "origin/${targetBranch}" 2>/dev/null`);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// best-effort
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// ignore
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { exitCode: 0 };
|
|
262
|
+
}
|
|
263
|
+
export function findWorktreeForBranch(porcelainOutput, branch) {
|
|
264
|
+
const blocks = porcelainOutput.split("\n\n");
|
|
265
|
+
for (const block of blocks) {
|
|
266
|
+
const lines = block.split("\n");
|
|
267
|
+
let worktreePath = "";
|
|
268
|
+
let branchRef = "";
|
|
269
|
+
for (const line of lines) {
|
|
270
|
+
if (line.startsWith("worktree "))
|
|
271
|
+
worktreePath = line.slice(9);
|
|
272
|
+
if (line.startsWith("branch "))
|
|
273
|
+
branchRef = line.slice(7);
|
|
274
|
+
}
|
|
275
|
+
if (branchRef === `refs/heads/${branch}` && worktreePath) {
|
|
276
|
+
return worktreePath;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
function conflictExit(output, targetBranch) {
|
|
282
|
+
return {
|
|
283
|
+
exitCode: 2,
|
|
284
|
+
stderr: `TRUNK-SYNC CONFLICT: ${HOOK_EXPLAINER} Another agent changed the same file, creating a merge conflict. The file now contains git conflict markers (<<<<<<< / ======= / >>>>>>>).\n\ngit output:\n${output}\n\nTo resolve: just read the conflicting file and edit it to the correct content (remove the conflict markers). This hook will detect the merge state and complete the sync automatically.`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function pushExit(output, targetBranch) {
|
|
288
|
+
return {
|
|
289
|
+
exitCode: 2,
|
|
290
|
+
stderr: `TRUNK-SYNC FAILED: ${HOOK_EXPLAINER} The push to remote failed.\n\ngit output:\n${output}\n\nTo resolve: run "git pull origin ${targetBranch} --no-rebase" then "git push origin HEAD:${targetBranch}". If there are conflicts, read the conflicting files and edit them to remove the conflict markers — the hook will complete the sync on your next edit.`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function escapeForShell(s) {
|
|
294
|
+
return s.replace(/"/g, '\\"');
|
|
295
|
+
}
|
|
296
|
+
function getExitCode(e) {
|
|
297
|
+
if (typeof e === "object" && e !== null && "status" in e) {
|
|
298
|
+
const status = e.status;
|
|
299
|
+
if (typeof status === "number")
|
|
300
|
+
return status;
|
|
301
|
+
}
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
304
|
+
function getStdout(e) {
|
|
305
|
+
if (typeof e === "object" && e !== null && "stdout" in e) {
|
|
306
|
+
return String(e.stdout);
|
|
307
|
+
}
|
|
308
|
+
if (e instanceof Error)
|
|
309
|
+
return e.message;
|
|
310
|
+
return String(e);
|
|
311
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HookInput, RepoState, HookPlan, CommitPlan } from "./hook-types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse the raw JSON string from hook stdin into a typed HookInput.
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseHookInput(json: string): HookInput;
|
|
6
|
+
/**
|
|
7
|
+
* Pure decision logic: given parsed input and repo state, decide what to do.
|
|
8
|
+
* No I/O, no git commands — only data in, plan out.
|
|
9
|
+
*/
|
|
10
|
+
export declare function planHook(input: HookInput, state: RepoState): HookPlan;
|
|
11
|
+
/**
|
|
12
|
+
* Build a commit plan with a task-based subject (when transcript extraction succeeds).
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildCommitPlanWithTask(input: HookInput, state: RepoState, task: string | null): CommitPlan;
|
|
15
|
+
export declare function buildSessionPrefix(sessionId: string | null): string;
|
|
16
|
+
export declare function buildCommitBody(input: HookInput, _relPath: string | null): string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Extract the first user message from a JSONL transcript.
|
|
19
|
+
* Filters out hook feedback, plan headers, XML tags, and empty lines.
|
|
20
|
+
* Returns first 72 chars or null.
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractTaskFromTranscript(content: string): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Summarize a list of deleted files: "file.txt (+2 more)"
|
|
25
|
+
*/
|
|
26
|
+
export declare function summarizeDeletions(files: string[]): string;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the raw JSON string from hook stdin into a typed HookInput.
|
|
3
|
+
*/
|
|
4
|
+
export function parseHookInput(json) {
|
|
5
|
+
const raw = JSON.parse(json);
|
|
6
|
+
return {
|
|
7
|
+
tool_name: raw.tool_name ?? null,
|
|
8
|
+
tool_input: raw.tool_input ?? {},
|
|
9
|
+
session_id: raw.session_id ?? null,
|
|
10
|
+
transcript_path: raw.transcript_path ?? null,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Pure decision logic: given parsed input and repo state, decide what to do.
|
|
15
|
+
* No I/O, no git commands — only data in, plan out.
|
|
16
|
+
*/
|
|
17
|
+
export function planHook(input, state) {
|
|
18
|
+
const filePath = input.tool_input.file_path ?? null;
|
|
19
|
+
const sync = buildSyncPlan(state);
|
|
20
|
+
// No file_path and no deleted files → nothing to do
|
|
21
|
+
if (!filePath && state.deletedFiles.length === 0) {
|
|
22
|
+
return { action: "skip" };
|
|
23
|
+
}
|
|
24
|
+
// File path provided but outside the repo → skip
|
|
25
|
+
if (filePath && !state.insideRepo) {
|
|
26
|
+
return { action: "skip" };
|
|
27
|
+
}
|
|
28
|
+
// File path provided but gitignored → skip
|
|
29
|
+
if (filePath && state.gitignored) {
|
|
30
|
+
return { action: "skip" };
|
|
31
|
+
}
|
|
32
|
+
// In merge state → complete the merge
|
|
33
|
+
if (state.inMerge) {
|
|
34
|
+
const relPath = filePath ? state.relPath : summarizeDeletions(state.deletedFiles);
|
|
35
|
+
const sessionPrefix = buildSessionPrefix(input.session_id);
|
|
36
|
+
const message = `${sessionPrefix}resolve merge conflict in ${relPath}`;
|
|
37
|
+
const filesToStage = filePath ? [filePath] : [];
|
|
38
|
+
return {
|
|
39
|
+
action: "commit-merge",
|
|
40
|
+
message,
|
|
41
|
+
sync,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// Normal commit path
|
|
45
|
+
const commit = buildCommitPlan(input, state);
|
|
46
|
+
return { action: "commit-and-sync", commit, sync };
|
|
47
|
+
}
|
|
48
|
+
function buildSyncPlan(state) {
|
|
49
|
+
if (!state.hasRemote)
|
|
50
|
+
return null;
|
|
51
|
+
return {
|
|
52
|
+
targetBranch: state.targetBranch,
|
|
53
|
+
currentBranch: state.currentBranch,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function buildCommitPlan(input, state) {
|
|
57
|
+
const filePath = input.tool_input.file_path ?? null;
|
|
58
|
+
const action = filePath
|
|
59
|
+
? (input.tool_name ?? "update").toLowerCase()
|
|
60
|
+
: "delete";
|
|
61
|
+
const relPath = filePath
|
|
62
|
+
? state.relPath
|
|
63
|
+
: summarizeDeletions(state.deletedFiles);
|
|
64
|
+
const filesToStage = filePath ? [filePath] : [];
|
|
65
|
+
const filesToRemove = filePath ? [] : state.deletedFiles;
|
|
66
|
+
const sessionPrefix = buildSessionPrefix(input.session_id);
|
|
67
|
+
const subject = `${sessionPrefix}${action} ${relPath}`;
|
|
68
|
+
const body = buildCommitBody(input, filePath ? relPath : null);
|
|
69
|
+
return { filesToStage, filesToRemove, subject, body };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build a commit plan with a task-based subject (when transcript extraction succeeds).
|
|
73
|
+
*/
|
|
74
|
+
export function buildCommitPlanWithTask(input, state, task) {
|
|
75
|
+
const base = buildCommitPlan(input, state);
|
|
76
|
+
if (!task)
|
|
77
|
+
return base;
|
|
78
|
+
const filePath = input.tool_input.file_path ?? null;
|
|
79
|
+
const relPath = filePath ? state.relPath : summarizeDeletions(state.deletedFiles);
|
|
80
|
+
const sessionPrefix = buildSessionPrefix(input.session_id);
|
|
81
|
+
const subject = `${sessionPrefix}${task}`;
|
|
82
|
+
// When task is present, include File: line in body
|
|
83
|
+
let body = `File: ${relPath}`;
|
|
84
|
+
if (input.session_id)
|
|
85
|
+
body += `\nSession: ${input.session_id}`;
|
|
86
|
+
return { ...base, subject, body: body || null };
|
|
87
|
+
}
|
|
88
|
+
export function buildSessionPrefix(sessionId) {
|
|
89
|
+
if (sessionId)
|
|
90
|
+
return `auto(${sessionId.slice(0, 8)}): `;
|
|
91
|
+
return "auto: ";
|
|
92
|
+
}
|
|
93
|
+
export function buildCommitBody(input, _relPath) {
|
|
94
|
+
if (!input.session_id)
|
|
95
|
+
return null;
|
|
96
|
+
return `Session: ${input.session_id}`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Extract the first user message from a JSONL transcript.
|
|
100
|
+
* Filters out hook feedback, plan headers, XML tags, and empty lines.
|
|
101
|
+
* Returns first 72 chars or null.
|
|
102
|
+
*/
|
|
103
|
+
export function extractTaskFromTranscript(content) {
|
|
104
|
+
const lines = content.split("\n");
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
if (!line.trim())
|
|
107
|
+
continue;
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(line);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!isUserMessage(parsed))
|
|
116
|
+
continue;
|
|
117
|
+
const msg = parsed.message;
|
|
118
|
+
const texts = extractTextContent(msg.content);
|
|
119
|
+
for (const text of texts) {
|
|
120
|
+
const candidate = filterTaskLine(text);
|
|
121
|
+
if (candidate)
|
|
122
|
+
return candidate.slice(0, 72);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
function isUserMessage(obj) {
|
|
128
|
+
if (typeof obj !== "object" || obj === null)
|
|
129
|
+
return false;
|
|
130
|
+
const rec = obj;
|
|
131
|
+
if (rec.type !== "user")
|
|
132
|
+
return false;
|
|
133
|
+
if (typeof rec.message !== "object" || rec.message === null)
|
|
134
|
+
return false;
|
|
135
|
+
const msg = rec.message;
|
|
136
|
+
return msg.role === "user";
|
|
137
|
+
}
|
|
138
|
+
function extractTextContent(content) {
|
|
139
|
+
if (typeof content === "string")
|
|
140
|
+
return [content];
|
|
141
|
+
if (Array.isArray(content)) {
|
|
142
|
+
return content.filter((item) => typeof item === "string");
|
|
143
|
+
}
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
function filterTaskLine(text) {
|
|
147
|
+
const lines = text.split("\n");
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
const trimmed = line.trim();
|
|
150
|
+
if (!trimmed)
|
|
151
|
+
continue;
|
|
152
|
+
if (trimmed.startsWith("Stop hook feedback:"))
|
|
153
|
+
return null;
|
|
154
|
+
if (trimmed === "Implement the following plan:")
|
|
155
|
+
continue;
|
|
156
|
+
if (trimmed.startsWith("<"))
|
|
157
|
+
continue;
|
|
158
|
+
// Strip leading markdown headers
|
|
159
|
+
const stripped = trimmed.replace(/^#{1,}\s+/, "");
|
|
160
|
+
if (stripped)
|
|
161
|
+
return stripped;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Summarize a list of deleted files: "file.txt (+2 more)"
|
|
167
|
+
*/
|
|
168
|
+
export function summarizeDeletions(files) {
|
|
169
|
+
if (files.length === 0)
|
|
170
|
+
return "";
|
|
171
|
+
const first = files[0];
|
|
172
|
+
if (files.length === 1)
|
|
173
|
+
return first;
|
|
174
|
+
return `${first} (+${files.length - 1} more)`;
|
|
175
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Raw JSON from Claude's PostToolUse hook stdin */
|
|
2
|
+
export interface HookInput {
|
|
3
|
+
tool_name: string | null;
|
|
4
|
+
tool_input: {
|
|
5
|
+
file_path?: string;
|
|
6
|
+
};
|
|
7
|
+
session_id: string | null;
|
|
8
|
+
transcript_path: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** Git state gathered before planning */
|
|
11
|
+
export interface RepoState {
|
|
12
|
+
repoRoot: string;
|
|
13
|
+
gitDir: string;
|
|
14
|
+
/** file_path relative to repoRoot, or null if no file_path */
|
|
15
|
+
relPath: string | null;
|
|
16
|
+
/** true when file_path is inside the repo */
|
|
17
|
+
insideRepo: boolean;
|
|
18
|
+
/** true when file_path is gitignored */
|
|
19
|
+
gitignored: boolean;
|
|
20
|
+
/** true when origin remote exists */
|
|
21
|
+
hasRemote: boolean;
|
|
22
|
+
/** default branch on origin (e.g. "main"), empty when no remote */
|
|
23
|
+
targetBranch: string;
|
|
24
|
+
/** current branch name */
|
|
25
|
+
currentBranch: string;
|
|
26
|
+
/** true when MERGE_HEAD exists */
|
|
27
|
+
inMerge: boolean;
|
|
28
|
+
/** true when staging area has changes */
|
|
29
|
+
hasStagedChanges: boolean;
|
|
30
|
+
/** tracked files that have been deleted from the working tree */
|
|
31
|
+
deletedFiles: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface SyncPlan {
|
|
34
|
+
targetBranch: string;
|
|
35
|
+
currentBranch: string;
|
|
36
|
+
}
|
|
37
|
+
export interface CommitPlan {
|
|
38
|
+
filesToStage: string[];
|
|
39
|
+
filesToRemove: string[];
|
|
40
|
+
subject: string;
|
|
41
|
+
body: string | null;
|
|
42
|
+
}
|
|
43
|
+
export type HookPlan = {
|
|
44
|
+
action: "skip";
|
|
45
|
+
} | {
|
|
46
|
+
action: "commit-and-sync";
|
|
47
|
+
commit: CommitPlan;
|
|
48
|
+
sync: SyncPlan | null;
|
|
49
|
+
} | {
|
|
50
|
+
action: "commit-merge";
|
|
51
|
+
message: string;
|
|
52
|
+
sync: SyncPlan | null;
|
|
53
|
+
};
|
|
54
|
+
export declare const HOOK_EXPLAINER = "A PostToolUse hook automatically commits and syncs every file change to keep multiple agents in sync on trunk.";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const HOOK_EXPLAINER = "A PostToolUse hook automatically commits and syncs every file change to keep multiple agents in sync on trunk.";
|
package/hooks/hooks.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@susu-eng/trunk-sync",
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Maximum continuous integration for multi-agent coding — every edit is committed and pushed to trunk immediately",
|
|
6
|
+
"bin": {
|
|
7
|
+
"trunk-sync": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/**/*.js",
|
|
11
|
+
"dist/**/*.d.ts",
|
|
12
|
+
"!dist/**/*.test.*",
|
|
13
|
+
"scripts/",
|
|
14
|
+
"hooks/",
|
|
15
|
+
"rules/",
|
|
16
|
+
".claude-plugin/"
|
|
17
|
+
],
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^22",
|
|
20
|
+
"tsx": "^4",
|
|
21
|
+
"typescript": "^5"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/elimydlarz/trunk-sync"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"claude-code",
|
|
30
|
+
"multi-agent",
|
|
31
|
+
"git",
|
|
32
|
+
"sync",
|
|
33
|
+
"trunk-based-development"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"postbuild": "chmod +x dist/cli.js",
|
|
38
|
+
"dev": "tsx src/cli.ts",
|
|
39
|
+
"test": "node --test --test-reporter spec 'dist/**/*.test.js'"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Trunk Sync
|
|
2
|
+
|
|
3
|
+
Every file write is auto-committed and pushed by a PostToolUse hook. Every edit is integrated to `origin/main` immediately — maximum continuous integration. This keeps multiple agents working against near-current trunk at all times.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
Each agent runs in its own git worktree (via `claude -w`), isolated from other agents. After every `Edit` or `Write`, the hook:
|
|
8
|
+
|
|
9
|
+
1. Commits the changed file with agent context
|
|
10
|
+
2. Pulls from `origin/main` (`--no-rebase`) and pushes to `origin/main`
|
|
11
|
+
3. Retries once if another agent pushed between pull and push
|
|
12
|
+
|
|
13
|
+
If another agent changed the same file, you get a merge conflict. The conflict and resolution work identically whether the other agent is in a local worktree or on a remote machine.
|
|
14
|
+
|
|
15
|
+
## When you see TRUNK-SYNC CONFLICT
|
|
16
|
+
|
|
17
|
+
Another agent changed the same file. Git left conflict markers in the file. Just read the file, edit it to the correct content (remove the `<<<<<<<` / `=======` / `>>>>>>>` markers), and the hook will detect the merge state and complete the sync automatically on your next edit.
|
|
18
|
+
|
|
19
|
+
Do NOT run git commands to resolve — just fix the file contents.
|
|
20
|
+
|
|
21
|
+
## Before you start
|
|
22
|
+
|
|
23
|
+
Run `git pull` once at the beginning of your session to start from the latest trunk. After that, the hook handles all pulls and pushes.
|
|
24
|
+
|
|
25
|
+
## Don't
|
|
26
|
+
|
|
27
|
+
- Make manual git commits — the hook handles it
|
|
28
|
+
- Run `git pull` or `git push` after your initial pull — the hook handles it
|
|
29
|
+
- Edit or delete files in `.transcripts/` — these are auto-generated session snapshots
|