atris 3.71.3 → 3.72.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/atris/skills/campaigns/SKILL.md +1 -1
- package/bin/atris.js +7 -1
- package/commands/auth.js +29 -2
- package/commands/autoland.js +102 -2
- package/commands/campaign.js +86 -13
- package/commands/clean.js +60 -4
- package/commands/gm.js +1273 -7
- package/commands/member.js +28 -3
- package/commands/mission.js +72 -23
- package/commands/sync.js +2 -2
- package/commands/task.js +49 -13
- package/commands/zen.js +183 -0
- package/lib/auto-accept-certified.js +9 -4
- package/lib/campaign-cadence.js +15 -5
- package/lib/campaign-core.js +44 -17
- package/lib/campaign-loop.js +45 -14
- package/lib/campaign-reading.js +14 -1
- package/lib/campaign-rsi.js +28 -11
- package/lib/campaign-schedule.js +124 -0
- package/lib/campaign-store.js +34 -2
- package/lib/campaign-trace.js +40 -14
- package/lib/engine-resource.js +5 -1
- package/lib/gm-bet.js +2219 -0
- package/lib/gm-phone-page.js +1102 -0
- package/lib/gm-phone.js +682 -0
- package/lib/gm-season.js +1162 -0
- package/lib/gm-target.js +292 -0
- package/lib/job-code-verify.js +169 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +105 -14
- package/lib/member-progress.js +2 -2
- package/lib/member-report.js +11 -1
- package/lib/member-self-next-job.js +6 -1
- package/lib/member-up.js +17 -8
- package/lib/merged-review-close.js +206 -0
- package/lib/mission-protected-lane.js +52 -4
- package/lib/qr.js +338 -0
- package/lib/site-publish.js +16 -2
- package/lib/site.js +17 -7
- package/lib/yes-lock.js +530 -0
- package/lib/zen-apply.js +619 -0
- package/lib/zen-workspace.js +121 -0
- package/lib/zen.js +409 -0
- package/package.json +1 -1
- package/utils/api.js +15 -2
- package/utils/auth.js +8 -7
package/lib/zen-apply.js
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Bring a repo to zen state. Every step is recoverable: stashes become refs
|
|
4
|
+
// under refs/archive/, stray code and unpushed commits go to zen-backup/
|
|
5
|
+
// branches, side copies are removed only when clean (their branches stay),
|
|
6
|
+
// and only merged branches are deleted. Anything touched recently is left
|
|
7
|
+
// alone, because someone may be working on it.
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { spawnSync } = require('node:child_process');
|
|
13
|
+
const { runGit } = require('./checkout-sync');
|
|
14
|
+
const {
|
|
15
|
+
checkZen,
|
|
16
|
+
defaultBranch,
|
|
17
|
+
dirtyEntries,
|
|
18
|
+
FRESH_MINUTES,
|
|
19
|
+
gitOut,
|
|
20
|
+
headMovedSince,
|
|
21
|
+
isTeamState,
|
|
22
|
+
listWorktrees,
|
|
23
|
+
readTeamState,
|
|
24
|
+
mtime,
|
|
25
|
+
idleWithOnlyNotes,
|
|
26
|
+
lastTouched,
|
|
27
|
+
sideCopyLines,
|
|
28
|
+
scheduledCopies,
|
|
29
|
+
untrackedFiles,
|
|
30
|
+
ABANDONED_DAYS,
|
|
31
|
+
abandonedDaysFromEnv,
|
|
32
|
+
} = require('./zen');
|
|
33
|
+
const { workspaceZen } = require('./zen-workspace');
|
|
34
|
+
|
|
35
|
+
function plural(n, one, many) {
|
|
36
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stamp(now) {
|
|
40
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
41
|
+
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}-`
|
|
42
|
+
+ `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function firstLine(text) {
|
|
46
|
+
return String(text || '').trim().split('\n')[0] || 'git refused';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function chunks(list, size = 200) {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gitEnv(args, cwd, env) {
|
|
56
|
+
return spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60000, env: { ...process.env, ...env } });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function commonDir(root) {
|
|
60
|
+
const dir = gitOut(['rev-parse', '--git-common-dir'], root);
|
|
61
|
+
return path.resolve(root, dir || '.git');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A checkout whose HEAD moved recently is in use: someone just committed,
|
|
65
|
+
// switched, or created it.
|
|
66
|
+
function identityEnv(root) {
|
|
67
|
+
const env = {};
|
|
68
|
+
if (!gitOut(['config', 'user.name'], root)) {
|
|
69
|
+
env.GIT_AUTHOR_NAME = 'atris zen';
|
|
70
|
+
env.GIT_COMMITTER_NAME = 'atris zen';
|
|
71
|
+
}
|
|
72
|
+
if (!gitOut(['config', 'user.email'], root)) {
|
|
73
|
+
env.GIT_AUTHOR_EMAIL = 'zen@atris.local';
|
|
74
|
+
env.GIT_COMMITTER_EMAIL = 'zen@atris.local';
|
|
75
|
+
}
|
|
76
|
+
return env;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function archiveStashes(root, tag) {
|
|
80
|
+
const shas = (gitOut(['stash', 'list', '--format=%H'], root) || '').split('\n').filter(Boolean);
|
|
81
|
+
if (!shas.length) return null;
|
|
82
|
+
const prefix = `refs/archive/zen-${tag}`;
|
|
83
|
+
shas.forEach((sha, i) => {
|
|
84
|
+
runGit(['update-ref', `${prefix}/stash-${String(i).padStart(3, '0')}`, sha], root);
|
|
85
|
+
});
|
|
86
|
+
const archived = (gitOut(['for-each-ref', '--format=%(objectname)', prefix], root) || '')
|
|
87
|
+
.split('\n').filter(Boolean).length;
|
|
88
|
+
if (archived !== shas.length || runGit(['stash', 'clear'], root).status !== 0) {
|
|
89
|
+
return { left: 'could not archive the old stashes, so they stay where they are' };
|
|
90
|
+
}
|
|
91
|
+
return { done: `saved ${plural(shas.length, 'old stash', 'old stashes')} away`, saved: prefix };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// When the newest unsaved change in a side copy was made.
|
|
95
|
+
// Save every unsaved change in a side copy onto a branch. Returns the branch
|
|
96
|
+
// only when the branch provably holds all of it.
|
|
97
|
+
function saveSideCopy(root, checkout, tag) {
|
|
98
|
+
const index = path.join(os.tmpdir(), `atris-zen-side-${process.pid}-${Date.now()}`);
|
|
99
|
+
const env = { GIT_INDEX_FILE: index, ...identityEnv(checkout) };
|
|
100
|
+
try {
|
|
101
|
+
if (gitEnv(['read-tree', 'HEAD'], checkout, env).status !== 0) return null;
|
|
102
|
+
if (gitEnv(['add', '-A', '.'], checkout, env).status !== 0) return null;
|
|
103
|
+
const unsaved = gitEnv(['ls-files', '--others', '--exclude-standard'], checkout, env).stdout.trim();
|
|
104
|
+
if (unsaved || gitEnv(['diff-files', '--quiet'], checkout, env).status !== 0) return null;
|
|
105
|
+
const tree = gitEnv(['write-tree'], checkout, env).stdout.trim();
|
|
106
|
+
const name = path.basename(checkout).replace(/[^A-Za-z0-9._-]/g, '-');
|
|
107
|
+
const commit = gitEnv(['commit-tree', tree, '-p', 'HEAD', '-m', `atris zen: unsaved work from ${name}`], checkout, env)
|
|
108
|
+
.stdout.trim();
|
|
109
|
+
const branch = `zen-backup/${tag}-${name}`;
|
|
110
|
+
if (!commit || runGit(['update-ref', `refs/heads/${branch}`, commit], root).status !== 0) return null;
|
|
111
|
+
return branch;
|
|
112
|
+
} finally {
|
|
113
|
+
fs.rmSync(index, { force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function putAwaySideCopies(root, cutoff, staleCutoff, tag, teamState) {
|
|
118
|
+
let removed = 0;
|
|
119
|
+
const busy = [];
|
|
120
|
+
const notesKept = [];
|
|
121
|
+
let inUse = 0;
|
|
122
|
+
let prune = false;
|
|
123
|
+
let scheduledCount = 0;
|
|
124
|
+
let locked = 0;
|
|
125
|
+
const kept = [];
|
|
126
|
+
const worktrees = listWorktrees(root);
|
|
127
|
+
const scheduled = scheduledCopies(worktrees);
|
|
128
|
+
for (const wt of worktrees) {
|
|
129
|
+
if (path.resolve(wt.path) === path.resolve(root)) continue;
|
|
130
|
+
if (scheduled.has(path.resolve(wt.path))) {
|
|
131
|
+
scheduledCount += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (wt.locked) {
|
|
135
|
+
locked += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (wt.prunable || !fs.existsSync(wt.path)) {
|
|
139
|
+
prune = true;
|
|
140
|
+
removed += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (headMovedSince(wt.path, cutoff)) {
|
|
144
|
+
inUse += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const status = runGit(['status', '--porcelain'], wt.path);
|
|
148
|
+
if (status.status !== 0) {
|
|
149
|
+
busy.push(wt.path);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (status.stdout.trim()) {
|
|
153
|
+
// Unsaved work nobody has touched in days goes to a branch, then away.
|
|
154
|
+
// So does an idle copy holding only team notes: nothing in it is work.
|
|
155
|
+
const notesOnly = idleWithOnlyNotes(wt.path, teamState, cutoff);
|
|
156
|
+
const abandoned = !headMovedSince(wt.path, staleCutoff) && lastTouched(wt.path) <= staleCutoff;
|
|
157
|
+
const branch = notesOnly || abandoned ? saveSideCopy(root, wt.path, tag) : null;
|
|
158
|
+
if (branch && runGit(['worktree', 'remove', '--force', wt.path], root).status === 0) {
|
|
159
|
+
(notesOnly && !abandoned ? notesKept : kept).push(branch);
|
|
160
|
+
} else busy.push(wt.path);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (runGit(['worktree', 'remove', wt.path], root).status === 0) removed += 1;
|
|
164
|
+
else busy.push(wt.path);
|
|
165
|
+
}
|
|
166
|
+
if (prune) runGit(['worktree', 'prune'], root);
|
|
167
|
+
return { removed, busy, inUse, kept, notesKept, scheduled: scheduledCount, locked };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function clearMergedBranches(root, upstream, main) {
|
|
171
|
+
const checkedOut = new Set(listWorktrees(root).map((wt) => wt.branch).filter(Boolean));
|
|
172
|
+
const current = gitOut(['symbolic-ref', '--quiet', '--short', 'HEAD'], root);
|
|
173
|
+
const merged = (gitOut(['branch', '--merged', upstream, '--format=%(refname:short)'], root) || '')
|
|
174
|
+
.split('\n')
|
|
175
|
+
.filter((name) => name && name !== main && name !== current && !checkedOut.has(name));
|
|
176
|
+
let cleared = 0;
|
|
177
|
+
for (const name of merged) {
|
|
178
|
+
if (runGit(['branch', '-q', '-d', name], root).status === 0) cleared += 1;
|
|
179
|
+
}
|
|
180
|
+
return cleared;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Save stray code edits on a zen-backup branch, then return those files to
|
|
184
|
+
// HEAD. Edits from the last few minutes are left alone.
|
|
185
|
+
function saveStrayEdits(root, teamState, tag, cutoff) {
|
|
186
|
+
const fresh = new Set();
|
|
187
|
+
const files = new Set();
|
|
188
|
+
for (const entry of dirtyEntries(root)) {
|
|
189
|
+
if (isTeamState(entry.path, teamState)) continue;
|
|
190
|
+
const paths = entry.xy === '??' && entry.path.endsWith('/')
|
|
191
|
+
? untrackedFiles(root, entry.path)
|
|
192
|
+
: [entry.path, entry.orig].filter(Boolean);
|
|
193
|
+
const isFresh = paths.some((p) => mtime(path.join(root, p)) > cutoff);
|
|
194
|
+
for (const p of paths) (isFresh ? fresh : files).add(p);
|
|
195
|
+
}
|
|
196
|
+
const result = { saved: 0, fresh: fresh.size, branch: null, left: null };
|
|
197
|
+
const list = [...files];
|
|
198
|
+
if (!list.length) return result;
|
|
199
|
+
if (runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], root).status !== 0) {
|
|
200
|
+
result.left = 'stray edits stay, because this repo has no commits yet';
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const index = path.join(os.tmpdir(), `atris-zen-index-${process.pid}-${Date.now()}`);
|
|
205
|
+
const env = { GIT_INDEX_FILE: index, ...identityEnv(root) };
|
|
206
|
+
try {
|
|
207
|
+
gitEnv(['read-tree', 'HEAD'], root, env);
|
|
208
|
+
for (const part of chunks(list)) gitEnv(['add', '-A', '--', ...part], root, env);
|
|
209
|
+
const tree = gitEnv(['write-tree'], root, env).stdout.trim();
|
|
210
|
+
const commit = gitEnv(['commit-tree', tree, '-p', 'HEAD', '-m', `atris zen: stray edits saved ${tag}`], root, env)
|
|
211
|
+
.stdout.trim();
|
|
212
|
+
const branch = `zen-backup/${tag}`;
|
|
213
|
+
if (!commit || runGit(['update-ref', `refs/heads/${branch}`, commit], root).status !== 0) {
|
|
214
|
+
result.left = 'could not save the stray edits, so they stay in the tree';
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Only touch files the backup really holds.
|
|
219
|
+
const held = new Set();
|
|
220
|
+
for (const part of chunks(list)) {
|
|
221
|
+
(gitOut(['ls-tree', '-r', '--name-only', commit, '--', ...part], root) || '')
|
|
222
|
+
.split('\n').filter(Boolean).forEach((p) => held.add(p));
|
|
223
|
+
}
|
|
224
|
+
const inHead = new Set();
|
|
225
|
+
for (const part of chunks(list)) {
|
|
226
|
+
(gitOut(['ls-tree', '-r', '--name-only', 'HEAD', '--', ...part], root) || '')
|
|
227
|
+
.split('\n').filter(Boolean).forEach((p) => inHead.add(p));
|
|
228
|
+
}
|
|
229
|
+
const restore = list.filter((p) => inHead.has(p));
|
|
230
|
+
const remove = list.filter((p) => !inHead.has(p) && held.has(p));
|
|
231
|
+
for (const part of chunks(restore)) runGit(['checkout', '-q', 'HEAD', '--', ...part], root);
|
|
232
|
+
for (const part of chunks(remove)) runGit(['rm', '-q', '--cached', '--ignore-unmatch', '--', ...part], root);
|
|
233
|
+
for (const p of remove) {
|
|
234
|
+
fs.rmSync(path.join(root, p), { force: true });
|
|
235
|
+
let dir = path.dirname(path.join(root, p));
|
|
236
|
+
while (dir.startsWith(root) && dir !== root) {
|
|
237
|
+
try {
|
|
238
|
+
fs.rmdirSync(dir);
|
|
239
|
+
} catch {
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
dir = path.dirname(dir);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
result.saved = restore.length + remove.length;
|
|
246
|
+
result.branch = branch;
|
|
247
|
+
return result;
|
|
248
|
+
} finally {
|
|
249
|
+
fs.rmSync(index, { force: true });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Files a 3-way apply left with conflict marks. Each goes back to the new
|
|
254
|
+
// version, so a team note never holds <<<<<<< lines.
|
|
255
|
+
function settleClashes(root, files) {
|
|
256
|
+
const clashed = (gitOut(['diff', '--name-only', '--diff-filter=U', '--', ...files], root) || '')
|
|
257
|
+
.split('\n').filter(Boolean);
|
|
258
|
+
runGit(['reset', '-q', '--', ...files], root);
|
|
259
|
+
for (const p of clashed) runGit(['checkout', '-q', 'HEAD', '--', p], root);
|
|
260
|
+
return clashed;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Lay a saved patch of team notes back down. One file that cannot go back
|
|
264
|
+
// (say its note was deleted upstream) makes git refuse the whole patch, so on
|
|
265
|
+
// that refusal each file goes back on its own and only the misfits stay out.
|
|
266
|
+
function applyNotes(root, patchFile, files) {
|
|
267
|
+
const whole = runGit(['apply', '-3', '--binary', patchFile], root);
|
|
268
|
+
const clashed = settleClashes(root, files);
|
|
269
|
+
if (whole.status === 0 || clashed.length) return { clashed, missed: [] };
|
|
270
|
+
const missed = [];
|
|
271
|
+
for (const p of files) {
|
|
272
|
+
const one = runGit(['apply', '-3', '--binary', `--include=${p.replace(/[*?[\\]/g, '\\$&')}`, patchFile], root);
|
|
273
|
+
if (settleClashes(root, [p]).length) clashed.push(p);
|
|
274
|
+
else if (one.status !== 0) missed.push(p);
|
|
275
|
+
}
|
|
276
|
+
return { clashed, missed };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function sameBytes(a, b) {
|
|
280
|
+
try {
|
|
281
|
+
return fs.readFileSync(a).equals(fs.readFileSync(b));
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A live journal only grows, so two versions of one day's log hold no real
|
|
288
|
+
// conflict: keep the incoming log and add each line of yours it lacks, in
|
|
289
|
+
// your order. Archived journals are finished and keep the usual handling.
|
|
290
|
+
function isJournal(p) {
|
|
291
|
+
return /(^|\/)logs\/.+\.md$/.test(p) && !/(^|\/)archive\//.test(p);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function joinJournal(file, mine) {
|
|
295
|
+
let incoming;
|
|
296
|
+
let yours;
|
|
297
|
+
try {
|
|
298
|
+
incoming = fs.readFileSync(file, 'utf8');
|
|
299
|
+
yours = fs.readFileSync(mine, 'utf8');
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
const have = new Set(incoming.split('\n').map((line) => line.trim()).filter(Boolean));
|
|
304
|
+
const extra = yours.split('\n').filter((line) => line.trim() && !have.has(line.trim()));
|
|
305
|
+
if (extra.length) fs.writeFileSync(file, `${incoming.replace(/\n*$/, '\n')}\n${extra.join('\n')}\n`);
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Notes moved to a new path between two commits, old path to new.
|
|
310
|
+
function movedNotes(root, from, to) {
|
|
311
|
+
const out = runGit(['diff', '--name-status', '-z', '-M', from, to], root).stdout || '';
|
|
312
|
+
const parts = out.split('\0');
|
|
313
|
+
const moves = new Map();
|
|
314
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
315
|
+
if (/^[RC]\d*$/.test(parts[i])) {
|
|
316
|
+
if (parts[i][0] === 'R') moves.set(parts[i + 1], parts[i + 2]);
|
|
317
|
+
i += 2;
|
|
318
|
+
} else if (parts[i]) {
|
|
319
|
+
i += 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return moves;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Carry your edits to a note onto the path it moved to, with a 3-way merge
|
|
326
|
+
// against the version you started from. Only a clean merge is written.
|
|
327
|
+
function followMove(root, fromCommit, from, to, mine) {
|
|
328
|
+
const dest = path.join(root, to);
|
|
329
|
+
const base = runGit(['show', `${fromCommit}:${from}`], root);
|
|
330
|
+
if (base.status !== 0 || !fs.existsSync(mine) || !fs.existsSync(dest)) return false;
|
|
331
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-zen-move-'));
|
|
332
|
+
try {
|
|
333
|
+
fs.writeFileSync(path.join(work, 'base'), base.stdout);
|
|
334
|
+
const merged = spawnSync('git', ['merge-file', '-p', dest, path.join(work, 'base'), mine], { encoding: 'utf8' });
|
|
335
|
+
if (merged.status !== 0) return false;
|
|
336
|
+
fs.writeFileSync(dest, merged.stdout);
|
|
337
|
+
return true;
|
|
338
|
+
} finally {
|
|
339
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Run a fast-forward or reset. If uncommitted team notes are in the way, save
|
|
344
|
+
// them, move, then lay them back on top. `from` is where the saved changes
|
|
345
|
+
// start: dropping unpushed commits or leaving a side branch passes the base,
|
|
346
|
+
// so a note committed there and edited again since comes back whole, not
|
|
347
|
+
// just the later edit.
|
|
348
|
+
function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
|
|
349
|
+
const first = runGit(args, root);
|
|
350
|
+
if (first.status === 0) return { ok: true, notes: [], tracked: [], landed: [] };
|
|
351
|
+
|
|
352
|
+
// Without rename detection a note moved upstream shows up under its old
|
|
353
|
+
// path too, so an edit to it is saved instead of blocking the move.
|
|
354
|
+
const changed = new Set((gitOut(['diff', '--name-only', '--no-renames', 'HEAD', target], root) || '').split('\n').filter(Boolean));
|
|
355
|
+
const moves = movedNotes(root, 'HEAD', target);
|
|
356
|
+
const fromCommit = gitOut(['rev-parse', '--verify', '--quiet', `${from}^{commit}`], root);
|
|
357
|
+
const dirty = dirtyEntries(root);
|
|
358
|
+
// A new note that was only staged has nothing in HEAD to go back to, so it
|
|
359
|
+
// is unstaged and handled like any untracked note.
|
|
360
|
+
const added = dirty.filter((e) => e.xy[0] === 'A' && changed.has(e.path)).map((e) => e.path);
|
|
361
|
+
if (added.length) runGit(['reset', '-q', '--', ...added], root);
|
|
362
|
+
const tracked = dirty.filter((e) => e.xy !== '??' && e.xy[0] !== 'A' && changed.has(e.path)).map((e) => e.path);
|
|
363
|
+
const untracked = dirty
|
|
364
|
+
.filter((e) => e.xy === '??')
|
|
365
|
+
.flatMap((e) => (e.path.endsWith('/') ? untrackedFiles(root, e.path) : [e.path]))
|
|
366
|
+
.filter((p) => changed.has(p))
|
|
367
|
+
.concat(added);
|
|
368
|
+
if (!tracked.length && !untracked.length) return { ok: false, error: firstLine(first.stderr) };
|
|
369
|
+
|
|
370
|
+
const dir = path.join(commonDir(root), 'atris-zen', tag);
|
|
371
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
372
|
+
const patchFile = path.join(dir, 'team-notes.patch');
|
|
373
|
+
if (tracked.length) {
|
|
374
|
+
const diff = runGit(['diff', '--binary', from, '--', ...tracked], root);
|
|
375
|
+
fs.writeFileSync(patchFile, diff.stdout);
|
|
376
|
+
for (const p of tracked) {
|
|
377
|
+
if (!fs.existsSync(path.join(root, p))) continue;
|
|
378
|
+
const mine = path.join(dir, 'mine', p);
|
|
379
|
+
fs.mkdirSync(path.dirname(mine), { recursive: true });
|
|
380
|
+
fs.copyFileSync(path.join(root, p), mine);
|
|
381
|
+
}
|
|
382
|
+
runGit(['checkout', '-q', 'HEAD', '--', ...tracked], root);
|
|
383
|
+
}
|
|
384
|
+
for (const p of untracked) {
|
|
385
|
+
const dest = path.join(dir, 'untracked', p);
|
|
386
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
387
|
+
fs.copyFileSync(path.join(root, p), dest);
|
|
388
|
+
fs.rmSync(path.join(root, p), { force: true });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const second = runGit(args, root);
|
|
392
|
+
const notes = [];
|
|
393
|
+
let landed = [];
|
|
394
|
+
if (tracked.length) {
|
|
395
|
+
const { clashed, missed } = applyNotes(root, patchFile, tracked);
|
|
396
|
+
landed = tracked.filter((p) => !clashed.includes(p) && !missed.includes(p));
|
|
397
|
+
for (const p of clashed) {
|
|
398
|
+
const mine = path.join(dir, 'mine', p);
|
|
399
|
+
if (isJournal(p) && joinJournal(path.join(root, p), mine)) notes.push(`joined your ${p} onto the new one`);
|
|
400
|
+
else notes.push(`kept the new ${p}; your copy is in ${mine}`);
|
|
401
|
+
}
|
|
402
|
+
for (const p of missed) {
|
|
403
|
+
const mine = path.join(dir, 'mine', p);
|
|
404
|
+
const to = moves.get(p);
|
|
405
|
+
if (to && fromCommit && followMove(root, fromCommit, p, to, mine)) {
|
|
406
|
+
notes.push(`${p} moved to ${to}, and your edits went with it`);
|
|
407
|
+
} else if (to) {
|
|
408
|
+
notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
|
|
409
|
+
} else {
|
|
410
|
+
notes.push(`${p} no longer fits the new changes; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const p of untracked) {
|
|
415
|
+
const saved = path.join(dir, 'untracked', p);
|
|
416
|
+
if (fs.existsSync(path.join(root, p))) {
|
|
417
|
+
// A copy that matches the incoming file loses nothing, so say nothing.
|
|
418
|
+
if (sameBytes(saved, path.join(root, p))) fs.rmSync(saved, { force: true });
|
|
419
|
+
else if (isJournal(p) && joinJournal(path.join(root, p), saved)) notes.push(`joined your ${p} onto the new one`);
|
|
420
|
+
else notes.push(`kept the new ${p}; your copy is in ${saved}`);
|
|
421
|
+
} else {
|
|
422
|
+
fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true });
|
|
423
|
+
fs.copyFileSync(saved, path.join(root, p));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (second.status !== 0) return { ok: false, error: firstLine(second.stderr), notes, tracked, landed };
|
|
427
|
+
return { ok: true, notes, tracked, landed };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Unpushed commits can carry team-note edits. Their commits move to a backup
|
|
431
|
+
// branch, but the notes stay in the tree, uncommitted, like any team note.
|
|
432
|
+
// A note that was also edited since came back with the move already, commit
|
|
433
|
+
// and all, so it is counted and not laid down twice.
|
|
434
|
+
function carryTeamNotes(root, base, commit, teamState, tag, moved = { tracked: [], landed: [] }) {
|
|
435
|
+
const touched = (gitOut(['diff', '--name-only', base, commit], root) || '')
|
|
436
|
+
.split('\n').filter((p) => p && isTeamState(p, teamState));
|
|
437
|
+
const already = touched.filter((p) => moved.landed.includes(p)).length;
|
|
438
|
+
const files = touched.filter((p) => !moved.tracked.includes(p));
|
|
439
|
+
if (!files.length) return { carried: already, notes: [] };
|
|
440
|
+
const diff = runGit(['diff', '--binary', base, commit, '--', ...files], root);
|
|
441
|
+
if (diff.status !== 0 || !diff.stdout) return { carried: already, notes: [] };
|
|
442
|
+
const dir = path.join(commonDir(root), 'atris-zen', tag);
|
|
443
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
444
|
+
const patchFile = path.join(dir, 'unpushed-team-notes.patch');
|
|
445
|
+
fs.writeFileSync(patchFile, diff.stdout);
|
|
446
|
+
const { clashed, missed } = applyNotes(root, patchFile, files);
|
|
447
|
+
const notes = clashed.map((p) => `kept the new ${p}; yours is on ${commit}`);
|
|
448
|
+
// A note moved upstream takes the commit's edits to its new path.
|
|
449
|
+
const moves = missed.length ? movedNotes(root, base, 'HEAD') : new Map();
|
|
450
|
+
let followed = 0;
|
|
451
|
+
for (const p of missed) {
|
|
452
|
+
const to = moves.get(p);
|
|
453
|
+
const theirs = to ? runGit(['show', `${commit}:${p}`], root) : null;
|
|
454
|
+
const mine = path.join(dir, 'unpushed', p);
|
|
455
|
+
if (theirs?.status === 0) {
|
|
456
|
+
fs.mkdirSync(path.dirname(mine), { recursive: true });
|
|
457
|
+
fs.writeFileSync(mine, theirs.stdout);
|
|
458
|
+
}
|
|
459
|
+
if (theirs?.status === 0 && followMove(root, base, p, to, mine)) {
|
|
460
|
+
notes.push(`${p} moved to ${to}, and the edits from ${commit} went with it`);
|
|
461
|
+
followed += 1;
|
|
462
|
+
} else if (to) {
|
|
463
|
+
notes.push(`${p} moved to ${to} and the edits clash there; yours is on ${commit}`);
|
|
464
|
+
} else {
|
|
465
|
+
notes.push(`${p} no longer fits the new changes; yours is on ${commit}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return { carried: already + followed + files.length - clashed.length - missed.length, notes };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDays, now = new Date() } = {}) {
|
|
472
|
+
const minutes = Number.isFinite(freshMinutes)
|
|
473
|
+
? freshMinutes
|
|
474
|
+
: Number.parseFloat(process.env.ATRIS_ZEN_FRESH_MINUTES || FRESH_MINUTES);
|
|
475
|
+
const staleDays = Number.isFinite(abandonedDays) ? abandonedDays : abandonedDaysFromEnv();
|
|
476
|
+
const before = checkZen({ cwd, fetch, freshMinutes: minutes, now: now.getTime() });
|
|
477
|
+
if (!before.ok) return before;
|
|
478
|
+
|
|
479
|
+
const root = before.root;
|
|
480
|
+
const tag = stamp(now);
|
|
481
|
+
const cutoff = now.getTime() - minutes * 60000;
|
|
482
|
+
const window = `the last ${plural(minutes, 'minute', 'minutes')}`;
|
|
483
|
+
const done = [];
|
|
484
|
+
const left = [];
|
|
485
|
+
const saved = [];
|
|
486
|
+
const notes = [];
|
|
487
|
+
const teamState = readTeamState(root);
|
|
488
|
+
const main = before.upstream ? defaultBranch(root, 'origin') : null;
|
|
489
|
+
const homeInUse = headMovedSince(root, cutoff);
|
|
490
|
+
|
|
491
|
+
const stashes = archiveStashes(root, tag);
|
|
492
|
+
if (stashes?.done) {
|
|
493
|
+
done.push(stashes.done);
|
|
494
|
+
saved.push(stashes.saved);
|
|
495
|
+
} else if (stashes?.left) {
|
|
496
|
+
left.push(stashes.left);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const copies = putAwaySideCopies(root, cutoff, now.getTime() - staleDays * 86400000, tag, teamState);
|
|
500
|
+
if (copies.removed) done.push(`put away ${plural(copies.removed, 'idle side copy', 'idle side copies')}`);
|
|
501
|
+
if (copies.notesKept.length) {
|
|
502
|
+
const n = copies.notesKept.length;
|
|
503
|
+
done.push(`saved the team notes from ${plural(n, 'idle side copy', 'idle side copies')} to zen-backup/ and put ${n === 1 ? 'it' : 'them'} away`);
|
|
504
|
+
saved.push(...copies.notesKept.map((b) => `refs/heads/${b}`));
|
|
505
|
+
}
|
|
506
|
+
if (copies.kept.length) {
|
|
507
|
+
done.push(`saved ${plural(copies.kept.length, 'side copy', 'side copies')} untouched for ${plural(staleDays, 'day', 'days')} to zen-backup/ and put ${copies.kept.length === 1 ? 'it' : 'them'} away`);
|
|
508
|
+
saved.push(...copies.kept.map((b) => `refs/heads/${b}`));
|
|
509
|
+
}
|
|
510
|
+
if (copies.busy.length) left.push(...sideCopyLines(copies.busy, staleDays));
|
|
511
|
+
if (copies.scheduled) left.push(`${plural(copies.scheduled, 'side copy is', 'side copies are')} used by a scheduled job, so ${copies.scheduled === 1 ? 'it stays' : 'they stay'}`);
|
|
512
|
+
if (copies.locked) left.push(`${plural(copies.locked, 'side copy is', 'side copies are')} locked by ${copies.locked === 1 ? 'its owner, so it stays' : 'their owners, so they stay'}`);
|
|
513
|
+
if (copies.inUse) left.push(`${plural(copies.inUse, 'side copy was', 'side copies were')} used in ${window}`);
|
|
514
|
+
|
|
515
|
+
const stray = saveStrayEdits(root, teamState, tag, cutoff);
|
|
516
|
+
if (stray.saved) {
|
|
517
|
+
done.push(`saved ${plural(stray.saved, 'stray edit', 'stray edits')} to ${stray.branch}`);
|
|
518
|
+
saved.push(`refs/heads/${stray.branch}`);
|
|
519
|
+
}
|
|
520
|
+
if (stray.fresh) left.push(`${plural(stray.fresh, 'edit was', 'edits were')} made in ${window}, so ${stray.fresh === 1 ? 'it stays' : 'they stay'}`);
|
|
521
|
+
if (stray.left) left.push(stray.left);
|
|
522
|
+
|
|
523
|
+
let branch = gitOut(['symbolic-ref', '--quiet', '--short', 'HEAD'], root);
|
|
524
|
+
if (main && branch !== main) {
|
|
525
|
+
const stillStray = dirtyEntries(root).some((e) => !isTeamState(e.path, teamState));
|
|
526
|
+
if (stillStray || homeInUse) {
|
|
527
|
+
left.push(`stayed on ${branch || 'a detached commit'}, because it was used in ${window}`);
|
|
528
|
+
} else {
|
|
529
|
+
if (!branch) {
|
|
530
|
+
const keep = `zen-backup/${tag}-detached`;
|
|
531
|
+
runGit(['update-ref', `refs/heads/${keep}`, 'HEAD'], root);
|
|
532
|
+
saved.push(`refs/heads/${keep}`);
|
|
533
|
+
}
|
|
534
|
+
// Saved from where the branch left main, so a note committed on the
|
|
535
|
+
// branch and edited again since comes back whole.
|
|
536
|
+
const base = gitOut(['merge-base', 'HEAD', main], root);
|
|
537
|
+
const moved = moveKeepingNotes(root, ['checkout', '-q', main], main, tag, base || 'HEAD');
|
|
538
|
+
notes.push(...(moved.notes || []));
|
|
539
|
+
if (moved.ok) {
|
|
540
|
+
done.push(`moved back to ${main}${branch ? ` (${branch} is kept)` : ''}`);
|
|
541
|
+
branch = main;
|
|
542
|
+
} else {
|
|
543
|
+
left.push(`could not move to ${main}: ${moved.error}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (main && branch === main) {
|
|
549
|
+
const counts = gitOut(['rev-list', '--left-right', '--count', `HEAD...${before.upstream}`], root) || '0 0';
|
|
550
|
+
const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
|
|
551
|
+
if (ahead && homeInUse) {
|
|
552
|
+
left.push(`${plural(ahead, 'unpushed commit stays', 'unpushed commits stay')}, made in ${window}`);
|
|
553
|
+
} else if (ahead) {
|
|
554
|
+
const keep = `zen-backup/${tag}-unpushed`;
|
|
555
|
+
runGit(['update-ref', `refs/heads/${keep}`, 'HEAD'], root);
|
|
556
|
+
const base = gitOut(['merge-base', keep, before.upstream], root);
|
|
557
|
+
const moved = moveKeepingNotes(root, ['reset', '-q', '--keep', before.upstream], before.upstream, tag, base || 'HEAD');
|
|
558
|
+
notes.push(...(moved.notes || []));
|
|
559
|
+
if (moved.ok) {
|
|
560
|
+
done.push(`moved ${plural(ahead, 'unpushed commit', 'unpushed commits')} to ${keep}`);
|
|
561
|
+
const carried = base ? carryTeamNotes(root, base, keep, teamState, tag, moved) : { carried: 0, notes: [] };
|
|
562
|
+
notes.push(...carried.notes);
|
|
563
|
+
if (carried.carried) {
|
|
564
|
+
done.push(`kept ${plural(carried.carried, 'team note', 'team notes')} from ${ahead === 1 ? 'it' : 'them'} in the tree`);
|
|
565
|
+
}
|
|
566
|
+
if (behind) done.push(`caught up ${plural(behind, 'commit', 'commits')} from ${before.upstream}`);
|
|
567
|
+
saved.push(`refs/heads/${keep}`);
|
|
568
|
+
} else {
|
|
569
|
+
runGit(['update-ref', '-d', `refs/heads/${keep}`], root);
|
|
570
|
+
left.push(`could not line up with ${before.upstream}: ${moved.error}`);
|
|
571
|
+
}
|
|
572
|
+
} else if (behind) {
|
|
573
|
+
const moved = moveKeepingNotes(root, ['merge', '-q', '--ff-only', before.upstream], before.upstream, tag);
|
|
574
|
+
notes.push(...(moved.notes || []));
|
|
575
|
+
if (moved.ok) done.push(`caught up ${plural(behind, 'commit', 'commits')} from ${before.upstream}`);
|
|
576
|
+
else left.push(`could not catch up with ${before.upstream}: ${moved.error}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (before.upstream) {
|
|
581
|
+
const cleared = clearMergedBranches(root, before.upstream, main);
|
|
582
|
+
if (cleared) done.push(`cleared ${plural(cleared, 'finished branch', 'finished branches')}`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const workspace = workspaceZen({ root });
|
|
586
|
+
|
|
587
|
+
const after = checkZen({ cwd: root, fetch: false, freshMinutes: minutes, now: now.getTime() });
|
|
588
|
+
const result = {
|
|
589
|
+
ok: true,
|
|
590
|
+
zen: after.zen,
|
|
591
|
+
root,
|
|
592
|
+
upstream: before.upstream,
|
|
593
|
+
already: before.zen && !done.length && !workspace.done.length,
|
|
594
|
+
workspace,
|
|
595
|
+
done,
|
|
596
|
+
left,
|
|
597
|
+
notes,
|
|
598
|
+
saved,
|
|
599
|
+
team_files: after.team_files,
|
|
600
|
+
remaining: after.problems.map((p) => p.text),
|
|
601
|
+
};
|
|
602
|
+
try {
|
|
603
|
+
fs.appendFileSync(path.join(commonDir(root), 'atris-zen.jsonl'), `${JSON.stringify({
|
|
604
|
+
at: now.toISOString(),
|
|
605
|
+
before: before.problems.map((p) => ({ kind: p.kind, count: p.count || 1 })),
|
|
606
|
+
after: after.problems.map((p) => ({ kind: p.kind, count: p.count || 1 })),
|
|
607
|
+
done,
|
|
608
|
+
left,
|
|
609
|
+
saved,
|
|
610
|
+
workspace: workspace.done,
|
|
611
|
+
yours: workspace.yours.map((item) => item.text),
|
|
612
|
+
})}\n`);
|
|
613
|
+
} catch {
|
|
614
|
+
// The receipt is a nicety; zen itself already happened.
|
|
615
|
+
}
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
module.exports = { applyZen, FRESH_MINUTES, ABANDONED_DAYS };
|