devlyn-cli 4.3.31 → 4.3.33

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.
Files changed (34) hide show
  1. package/README.md +4 -2
  2. package/bin/devlyn.js +109 -22
  3. package/bin/harness-activation.js +4 -1
  4. package/bin/harness-status.js +15 -8
  5. package/bin/skill-history.json +10 -0
  6. package/config/skills/_shared/bring-in.py +10 -0
  7. package/config/skills/_shared/task-complete.py +787 -87
  8. package/config/skills/_shared/task-completion.md +6 -6
  9. package/config/skills/_shared/warm-build-dirs.py +13 -0
  10. package/config/skills/devlyn-ideate/SKILL.md +3 -2
  11. package/config/skills/devlyn-ideate/references/loop.md +2 -2
  12. package/config/skills/devlyn-ideate/scripts/acceptance.py +11 -0
  13. package/config/skills/devlyn-ideate/scripts/queue.py +685 -10
  14. package/native/claude/.claude-plugin/plugin.json +1 -1
  15. package/native/claude/skills/_shared/bring-in.py +10 -0
  16. package/native/claude/skills/_shared/task-complete.py +787 -87
  17. package/native/claude/skills/_shared/task-completion.md +6 -6
  18. package/native/claude/skills/_shared/warm-build-dirs.py +13 -0
  19. package/native/claude/skills/devlyn-ideate/SKILL.md +3 -2
  20. package/native/claude/skills/devlyn-ideate/references/loop.md +2 -2
  21. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +11 -0
  22. package/native/claude/skills/devlyn-ideate/scripts/queue.py +685 -10
  23. package/native/codex/.agents/plugins/marketplace.json +1 -1
  24. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  25. package/native/codex/plugins/devlyn/skills/_shared/bring-in.py +10 -0
  26. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +787 -87
  27. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +6 -6
  28. package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +13 -0
  29. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +3 -2
  30. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +2 -2
  31. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +11 -0
  32. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +685 -10
  33. package/package.json +1 -1
  34. package/release.json +35 -35
package/README.md CHANGED
@@ -72,7 +72,9 @@ A bare intent is planned and, when your request authorizes the work, added and d
72
72
 
73
73
  Direct work edits your current checkout; run parallel sessions in separate worktrees (for example `claude -w`). Completed, verified direct requests with changes, and
74
74
  every drained task, are delivered by project policy from their own linked worktrees. A commit request stays local and a PR request stops at the PR; otherwise accepted tasks default to
75
- scoped commit → push → PR → merge when the repository allows it (otherwise the PR waits for a person). After the
75
+ scoped commit → push → PR → merge when the repository allows it (otherwise the PR waits for a person; while required
76
+ checks or reviews have not passed it stays pending, and running the printed resume after they pass merges it, or a person
77
+ merges it; with GitHub's Allow auto-merge on, delivery requests the merge, so it lands by itself). After the
76
78
  PR merges, the worktree and branch its session released are cleaned; anything in use is
77
79
  kept. Set `git config --local devlyn.completionMode pr` to stop at the PR; `task-complete.py complete --mode auto|pr`
78
80
  overrides one task. Local-only/no-push instructions take precedence: nothing is pushed or merged;
@@ -253,7 +255,7 @@ On native Windows, use native Node/npm and Python plus Git for Windows Bash for
253
255
 
254
256
  Drain worktrees default to `<repo parent>/.devlyn-worktrees/<repo name>/<loop-id>/<task-id>`. On Windows, a newly created shared container is marked hidden, and each worktree disables content indexing for new children; names stay indexed and Microsoft Defender scanning is unaffected. For Defender performance, use a Dev Drive via `--worktree-root` or an administrator's exclusion for the root; see [loop options](config/skills/devlyn-ideate/references/loop.md).
255
257
 
256
- Windows completion preserves the workspace, owned refs and recovery receipt when writer cessation cannot be proved, even after merge; resume guidance and delivery status remain separate from product verification. The portability workflow checks out the repository on native Windows and tests the POSIX-packed npm artifact there. A POSIX pass alone does not establish Windows support: acceptance requires the passing Windows job for the exact source SHA/artifact hashes.
258
+ Windows completion keeps a merged task's workspace, with its owned refs and recovery receipt, while a program has a file open in it or runs in it, and removes it once that program lets go; a workspace a program only watches may be removed. A managed workspace is kept the same way and removed by hand as the reason says; scratch that holds files is kept too. Resume guidance and delivery status remain separate from product verification. The portability workflow checks out the repository on native Windows and tests the POSIX-packed npm artifact there. A POSIX pass alone does not establish Windows support: acceptance requires the passing Windows job for the exact source SHA/artifact hashes.
257
259
 
258
260
  ## Contributing
259
261
 
package/bin/devlyn.js CHANGED
@@ -616,23 +616,46 @@ function installLocalSkill(skillName, roots, installedSkills = new Map()) {
616
616
  return true;
617
617
  }
618
618
 
619
- // One skill from `source` into one skill-loader directory. The copy is staged and checked first, so
620
- // a failed copy leaves any older copy of it in place for a plain rerun. A copy under its name
619
+ // One skill from `source` into one skill-loader directory. The copy is staged and checked first, and
620
+ // the older copy is moved aside into the stage before the new one is renamed in, and back if that
621
+ // fails, so a failed copy or swap leaves the older copy in place for a plain rerun. If moving it back
622
+ // or removing it after the swap fails, the error names where it is kept. A copy under its name
621
623
  // before the 4.0.0 rename goes only once the new one is complete.
622
624
  function installSkillInto(source, target, skillName, installedSkills) {
623
625
  const dest = path.join(target, skillName);
624
626
  const stage = fs.mkdtempSync(path.join(target, '.devlyn-skill-'));
625
627
  const stagedSkill = path.join(stage, skillName);
628
+ const previous = path.join(stage, 'previous');
629
+ let files;
630
+ let moved = false;
626
631
  try {
627
- const files = copyRecursive(path.join(source, skillName), stagedSkill, stage);
632
+ files = copyRecursive(path.join(source, skillName), stagedSkill, stage);
628
633
  assertCompleteSkillInstall(source, stage, [skillName]);
629
- fs.rmSync(dest, { recursive: true, force: true });
634
+ if (fs.lstatSync(dest, { throwIfNoEntry: false })) {
635
+ fs.renameSync(dest, previous);
636
+ moved = true;
637
+ }
630
638
  fs.renameSync(stagedSkill, dest);
631
- installedSkills.set(dest, files.map((file) => path.join(dest, path.relative(stagedSkill, file))));
632
- } finally {
639
+ } catch (error) {
640
+ if (moved) {
641
+ try { fs.renameSync(previous, dest); }
642
+ catch (rollback) {
643
+ throw new AggregateError([error, rollback], `${error.message}; ${rollback.message}; the previous copy is kept at ${previous}`);
644
+ }
645
+ }
646
+ try { fs.rmSync(stage, { recursive: true, force: true }); }
647
+ catch (cleanup) {
648
+ throw new AggregateError([error, cleanup], `${error.message}; ${dest} is as it was, but ${stage} could not be removed: ${cleanup.message}`);
649
+ }
650
+ throw new Error(`${error.message}; ${dest} is as it was`, { cause: error });
651
+ }
652
+ installedSkills.set(dest, files.map((file) => path.join(dest, path.relative(stagedSkill, file))));
653
+ try {
633
654
  fs.rmSync(stage, { recursive: true, force: true });
655
+ } catch (error) {
656
+ const kept = fs.lstatSync(previous, { throwIfNoEntry: false }) ? `its previous copy at ${previous}` : stage;
657
+ throw new Error(`${dest} is installed, but ${kept} could not be removed: ${error.message}`);
634
658
  }
635
-
636
659
  }
637
660
 
638
661
  // A 0.6.0-0.7.1 copy is one SKILL.md, the file that identifies it. A failed swap leaves
@@ -1440,19 +1463,78 @@ function cleanupRealParents(base, file) {
1440
1463
  return true;
1441
1464
  }
1442
1465
 
1443
- let cleanupProcesses;
1444
- // Whether a running process names `dir` on its command line (a drain started from a global copy runs
1445
- // its queue.py and acceptance.py by absolute path). Read once per command; unreadable means unknown.
1446
- function cleanupInUse(dir) {
1447
- if (cleanupProcesses === undefined) {
1448
- const listing = process.platform === 'win32'
1449
- ? spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command',
1450
- 'Get-CimInstance Win32_Process | ForEach-Object { $_.CommandLine }'], { encoding: 'utf8', timeout: 20000 })
1451
- : spawnSync('ps', ['-axww', '-o', 'command='], { encoding: 'utf8', timeout: 20000 });
1452
- cleanupProcesses = listing.status === 0 ? listing.stdout.split(/\r?\n/).filter((line) => line && !line.includes(' cleanup --')) : null;
1466
+ // The process list that decides whether a copy is in use (a drain started from a copy runs its queue.py and
1467
+ // acceptance.py by absolute path). A path is compared only with text the lister itself printed for that very
1468
+ // path, in a list whose rows are all rendered the same way. A list that cannot be read clears nothing.
1469
+ // POSIX: under the C locale `ps` renders each byte by itself, alike in every row, and the shell it runs from
1470
+ // holds each path between random tokens in its own row, the witness. Each shell's arguments fit one page,
1471
+ // which every kernel accepts and prints whole.
1472
+ const CLEANUP_LIST_BYTES = 4096;
1473
+ // Windows: each command line travels as its UTF-16 code units in decimal, which no code page changes, and this
1474
+ // marker in PowerShell's own command line must come back intact.
1475
+ const CLEANUP_LIST_MARKER = '✓한';
1476
+ const CLEANUP_LIST_COMMAND = `Get-CimInstance Win32_Process | ForEach-Object { ([int[]][char[]]"$($_.CommandLine)") -join ',' } # ${CLEANUP_LIST_MARKER}`;
1477
+
1478
+ // A Windows list, one command line per row; null when a row is malformed or the marker did not come back in
1479
+ // PowerShell's own row beside the command text before it (Node writes each `"` of the command there as `\"`).
1480
+ function cleanupWindowsRows(text) {
1481
+ const rows = [];
1482
+ for (const line of text.split(/\r?\n/)) {
1483
+ const units = line ? line.split(',') : [];
1484
+ if (!units.every((unit) => /^\d{1,5}$/.test(unit) && Number(unit) <= 0xffff)) return null;
1485
+ rows.push(String.fromCharCode(...units.map(Number)));
1486
+ }
1487
+ const own = CLEANUP_LIST_COMMAND.slice(CLEANUP_LIST_COMMAND.lastIndexOf('"') + 1);
1488
+ return rows.some((row) => row.includes(own)) ? rows : null;
1489
+ }
1490
+
1491
+ // A drain started from Git Bash may name a copy C:/… or /c/…: compared lower-cased, with slashes.
1492
+ function cleanupWindowsNames(row, dir) {
1493
+ const text = row.toLowerCase().replaceAll('\\', '/');
1494
+ const name = `${dir}\\`.toLowerCase().replaceAll('\\', '/');
1495
+ return text.includes(name) || (/^[a-z]:\//.test(name) && text.includes(`/${name[0]}${name.slice(2)}`));
1496
+ }
1497
+
1498
+ // Which of `dirs` a running process names on its command line.
1499
+ function cleanupInUse(dirs) {
1500
+ const options = { timeout: 20000, maxBuffer: 64 * 1024 * 1024 };
1501
+ const others = (rows) => rows.filter((row) => !row.includes(' cleanup --'));
1502
+ if (process.platform === 'win32') {
1503
+ const listing = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', CLEANUP_LIST_COMMAND], options);
1504
+ const rows = !listing.error && listing.status === 0 ? cleanupWindowsRows(listing.stdout.toString('latin1')) : null;
1505
+ return new Set(dirs.filter((dir) => !rows || others(rows).some((row) => cleanupWindowsNames(row, dir))));
1506
+ }
1507
+ const script = 'ps -axww -o command=; exit $?';
1508
+ // The shell's arguments with 16-letter tokens around the paths, each argument with its NUL.
1509
+ const size = (batch) => Buffer.byteLength(['sh', '-c', script, ...batch].join('\0')) + 1 + 17 * (batch.length + 1);
1510
+ const used = new Set();
1511
+ const batches = [];
1512
+ for (const name of new Set(dirs.map((dir) => dir + path.sep))) {
1513
+ if (batches.length && size([...batches.at(-1), name]) <= CLEANUP_LIST_BYTES) batches.at(-1).push(name);
1514
+ else if (size([name]) <= CLEANUP_LIST_BYTES) batches.push([name]);
1515
+ else used.add(name);
1516
+ }
1517
+ for (const batch of batches) {
1518
+ let tokens;
1519
+ do {
1520
+ tokens = [...batch, ''].map(() => crypto.randomBytes(8).toString('hex'));
1521
+ } while (new Set(tokens).size < tokens.length || tokens.some((token) => batch.some((name) => name.includes(token))));
1522
+ const listing = spawnSync('sh', ['-c', script, ...batch.flatMap((name, i) => [tokens[i], name]), tokens.at(-1)],
1523
+ { ...options, env: { ...process.env, LC_ALL: 'C' } });
1524
+ const rows = !listing.error && listing.status === 0 ? listing.stdout.toString('latin1').split('\n') : [];
1525
+ // The witness holds every token once, in order; `ps` joins arguments with one space, dropped on each side.
1526
+ const marks = (row) => tokens.map((token) => (row.split(token).length === 2 ? row.indexOf(token) : -1));
1527
+ const witnesses = rows.filter((row) => marks(row).every((mark, i, all) => mark >= 0 && (i === 0 || all[i - 1] < mark)));
1528
+ const [witness] = witnesses, at = witnesses.length === 1 && marks(witness);
1529
+ const between = at ? batch.map((_, i) => witness.slice(at[i] + tokens[i].length, at[i + 1])) : [];
1530
+ const framed = between.length && between.every((text) => text.length > 2 && text.startsWith(' ') && text.endsWith(' '));
1531
+ // The witness alone is no user: any other row naming a path is one, another cleanup's lister shell included.
1532
+ const listed = others(rows.filter((row) => row !== witness));
1533
+ batch.forEach((name, i) => {
1534
+ if (!framed || listed.some((row) => row.includes(between[i].slice(1, -1)))) used.add(name);
1535
+ });
1453
1536
  }
1454
- // A list that cannot be read cannot clear anything: the roots stay until it can.
1455
- return cleanupProcesses === null || cleanupProcesses.some((line) => line.includes(dir + path.sep));
1537
+ return new Set(dirs.filter((dir) => used.has(dir + path.sep)));
1456
1538
  }
1457
1539
 
1458
1540
  // Markers are generated, not bundled: require the exact writer format, a shipped version and
@@ -1478,7 +1560,7 @@ function cleanupMarkerOwned(file, current) {
1478
1560
  function cleanupSkillRoots(roots, current, callerBase = null) {
1479
1561
  const resolved = new Map();
1480
1562
  const linked = new Set();
1481
- const active = new Set();
1563
+ const spellings = [];
1482
1564
  for (const [base, root] of roots) {
1483
1565
  let target = root;
1484
1566
  const stat = fs.lstatSync(root, { throwIfNoEntry: false });
@@ -1498,8 +1580,11 @@ function cleanupSkillRoots(roots, current, callerBase = null) {
1498
1580
  } else if (parents && stat?.isDirectory()) target = fs.realpathSync.native(root);
1499
1581
  if (!resolved.has(target)) resolved.set(target, base);
1500
1582
  const callerPath = (file) => callerBase ? path.join(callerBase, path.relative(base, file)) : file;
1501
- if (cleanupInUse(callerPath(root)) || cleanupInUse(callerPath(target))) active.add(target);
1583
+ spellings.push([target, callerPath(root), callerPath(target)]);
1502
1584
  }
1585
+ // Every path is known before the list is taken: the lister prints each of them beside the rows it is sought in.
1586
+ const used = cleanupInUse(spellings.flatMap(([, ...names]) => names));
1587
+ const active = new Set(spellings.filter(([, ...names]) => names.some((name) => used.has(name))).map(([target]) => target));
1503
1588
  return [...resolved].flatMap(([root, base]) => {
1504
1589
  const items = cleanupSkillRoot(base, root, current);
1505
1590
  const callerRoot = callerBase ? path.join(callerBase, path.relative(base, root)) : root;
@@ -2256,7 +2341,9 @@ function cleanupDeliver(scope, items, current) {
2256
2341
  if (!prepared.acceptance) return cleanupUpdateCheckout(scope);
2257
2342
  const completed = lastJson(run(['complete', '--receipt', allocated.receipt, '--acceptance', prepared.acceptance, '--writers-stopped'], scope.root).stdout);
2258
2343
  const status = completed?.delivery_status || completed?.status;
2259
- if (status === 'COMPLETE' || status === 'MERGED') return { ...cleanupUpdateCheckout(scope), delivery: { state: 'merged', url: completed.pr || null } };
2344
+ // A merge whose workspace or scratch the helper retained (CLEANUP_PENDING) is not a clean merge: report its resume.
2345
+ if (status === 'COMPLETE' || status === 'MERGED') return { ...cleanupUpdateCheckout(scope), delivery: completed.status === 'CLEANUP_PENDING'
2346
+ ? { state: 'cleanup-pending', url: completed.pr || null, resume: completed.resume } : { state: 'merged', url: completed.pr || null } };
2260
2347
  if (completed?.pr) return { delivery: { state: 'pr-open', url: completed.pr } };
2261
2348
  const reason = completed?.reason || 'delivery did not complete';
2262
2349
  return { error: cleanupReason(failureCode(reason), { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
@@ -380,7 +380,10 @@ function inspectSkills(context, release, env, config, active) {
380
380
  for (const name of SKILLS) {
381
381
  const dir = path.join(root, name);
382
382
  try {
383
- if (!fs.lstatSync(dir, { throwIfNoEntry: false })) continue;
383
+ // A lookup through a file finds no entry: POSIX says ENOTDIR where Windows says "not found".
384
+ let entry;
385
+ try { entry = fs.lstatSync(dir, { throwIfNoEntry: false }); } catch (error) { if (error.code !== 'ENOTDIR') throw error; }
386
+ if (!entry) continue;
384
387
  const hash = fingerprint(dir);
385
388
  if (!hashes.has(name)) hashes.set(name, fingerprint(path.join(release.root, 'config/skills', name)));
386
389
  found.push({ name, path: path.join(dir, 'SKILL.md'), owned: hash === hashes.get(name) || (history.skills[name] || []).includes(hash) });
@@ -70,16 +70,23 @@ function requirements() {
70
70
  const probes = [
71
71
  ['python', true, 'python3', ['-B', '--version']],
72
72
  ['git', true, 'git', ['--version']],
73
- ['git-bash', process.platform === 'win32', 'bash', ['--version']],
74
- ['gh', false, 'gh', ['--version']],
73
+ // Git Bash is a bash whose builtin `pwd -W` (an MSYS2 patch) prints a Windows path; its version label no longer
74
+ // tells it from Cygwin's. -p reads no BASH_ENV or ENV file; --norc no ~/.bashrc, which a bash at shell level 1
75
+ // otherwise reads when SSH_CLIENT is set or stdin is a socket (Node's pipes are). Long options come first. macOS
76
+ // and Linux bashes refuse -W, so there this row (never required) is unavailable.
77
+ ['git-bash', process.platform === 'win32', 'bash', ['--norc', '-p', '-c', 'echo "$BASH_VERSION"; cd / && pwd -W']],
78
+ // gh 2.91 and later writes a telemetry device id into its state folder on every command unless this is off.
79
+ ['gh', false, 'gh', ['--version'], { GH_TELEMETRY: 'false' }],
75
80
  ];
76
- return [node, ...probes.map(([name, required, binary, argv]) => {
77
- const result = spawnSync(binary, argv, { encoding: 'utf8', timeout: 5000, env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
81
+ return [node, ...probes.map(([name, required, binary, argv, env]) => {
82
+ const result = spawnSync(binary, argv, { encoding: 'utf8', timeout: 5000, env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', ...env } });
78
83
  const output = (result.stdout || result.stderr || '').trim();
79
- const match = output.match(/\b(\d+)\.(\d+)(?:\.\d+)?/);
80
- let available = !result.error && result.status === 0 && Boolean(match);
84
+ const bash = name === 'git-bash';
85
+ // A bash's version is its $BASH_VERSION line, never digits in the path line after it.
86
+ const match = output.match(bash ? /^\d+\.\d+\.\d+(?=(?:\(\d+\)-\w+)?\r?$)/m : /\b(\d+)\.(\d+)(?:\.\d+)?/);
87
+ let available = !result.error && result.status === 0
88
+ && (bash ? /^(?:[A-Za-z]:\/|\/\/)/.test(output.split('\n').pop()) : Boolean(match));
81
89
  if (name === 'python' && available) available = Number(match[1]) > 3 || (Number(match[1]) === 3 && Number(match[2]) >= 11);
82
- if (name === 'git-bash' && required && available) available = /mingw|msys/i.test(output);
83
90
  return { name, required, available, version: match?.[0] || null,
84
91
  reasons: available ? [] : [{ code: 'requirement-unavailable', params: { name } }] };
85
92
  })];
@@ -168,4 +175,4 @@ function statusCommand(args) {
168
175
  return status;
169
176
  }
170
177
 
171
- module.exports = { harnessStatus, statusCommand, repositoryIdentity, inspectInstruction };
178
+ module.exports = { harnessStatus, statusCommand, repositoryIdentity, inspectInstruction, requirements };
@@ -57,6 +57,7 @@
57
57
  "406b982f6ca0b9f5c0faa6cb50749b52aaf23545e06174ec39f831929dc929bd",
58
58
  "40f50f3c3c0d030d1efeb6955c0c6f32f518e737d8dd3bec6610ff02badebbdb",
59
59
  "430d156eb8c62c7edba3d660050f90b267f928976c3a3688e95d54f00dc34747",
60
+ "446e47fc34c81ed4cb9f4332d446a93025ae3dc1791155a4b80d6f195255958e",
60
61
  "450d578ef177a9e8f0b693940ad64b41c1d9719975ba6305f62a7c82ac3a892f",
61
62
  "45ef27af9ec27089e2861c13b882521fc6bf073dc6b7a356b326b4a27c5e6e24",
62
63
  "4685be6a94ad5587ce53785c14a60cccf9a852d9d23391dba0a5bcd0c64931cf",
@@ -120,6 +121,7 @@
120
121
  "8c80926ab03b2477e52264495b35c2116f65fc51b02e8c56e441b5705872392a",
121
122
  "8fb73c0d2472b3643ac6e3f0ad87d818694afebaf68fcad596496e703893c43d",
122
123
  "9162fc2fed6f4a611d299485be3a397080beb3304865ce59b95576ba7d00650e",
124
+ "92ffe78e156d8f11f666a92b26d21ae52e147ed9cad7eb79efbef334a9373281",
123
125
  "94a14a1b87df1d7297d12949a24e47a0d6c16a8fe30495ee759fcabb2e1aa398",
124
126
  "978e4210e49b42827c98cf6e40c868c87dc6e3533a2b779ca2fa05abfc16ad0f",
125
127
  "97e7ad89c74c609b15dfbf6f9f7ac61df8b50e97e4bc318f72922e058ecffba4",
@@ -130,6 +132,7 @@
130
132
  "9e4d06fd466fa896040109bac0018fcfb2f1604d55abb4180ff1f1a0b665bbc8",
131
133
  "9f14ca629e41730b1a9fb5d0ad1a39cff558fe34758d6ceccbb9de498f9f384e",
132
134
  "a11950a5880346f03257c688fd8bc6f8c29fc0b7c05c90e8f5211d5fb7665774",
135
+ "a1d5a9505c527af88bcac9305ec8bce0ee01d472ec383e7ffacbcccf9137565b",
133
136
  "a405a99b988f6edd2eabb0504b36309bb4cf0c3b97eab26bb7ef257e4515dbf3",
134
137
  "a463693773934b6de78f5326fb17acac34e8159f459b89c21aa66f3ac0874a57",
135
138
  "a66080b0ff25f35e8574a24ee01d87de9f579a59796977f3cc59f1865063f6f6",
@@ -197,6 +200,7 @@
197
200
  "f2bcfa0471aba81a10e3d0e6fd43bd6ec670601a4ba3a7d719e595edf9ab032a",
198
201
  "f958bfabd2e056fea911526fba2196d2ed5a48828f576e5ce1657100b466db20",
199
202
  "f9617e1034543f31ae2a3820301fbac6e4674e5d6fbc0d4b76751fe87b8e013e",
203
+ "fbca65e47854425acd80087e89024fb9d2f44f853d88abe582fbee107a3e8de3",
200
204
  "fd68c7a74272929a14e685e447a51058ffcdd4c1d6bef83cbba18045c07aee55",
201
205
  "fdb608b34a8600b4ce209d7f645ec5ad4475948be6ad95c941cc7ef02b5959df",
202
206
  "fe357defe8ac6efd6bbd15b3bf35f79d79761c0f56d507488d9428536d4fb5f7",
@@ -350,6 +354,7 @@
350
354
  "05e824f63a6381e02042afb1df0f1437584e25a80de8e9db0bd9456aaeb7aab8",
351
355
  "0601ee535419b7039d5da34bd9c8e1c2b3631307214e4afaf394dd47494090c7",
352
356
  "062fde2838c0edc0c20e6716948a2ae9fe9d8a98b01a8e8c544c5801641c7cdb",
357
+ "0c54ebc6c95f04ee0ec2f21aa4ebe34f4d2cfb9697b45a2b02999b963bbbdbca",
353
358
  "0cef055580e00f9ed439eacb2dff9d238bb11619457472016095a8915162fa4b",
354
359
  "0dc69c20434966015176d83baa6f95b3421f9e13d0b1bd608a22f0560eae1049",
355
360
  "0fe9b270d670f70da9d2535969b6117684b7c1517806b3029ebaf9624da8006c",
@@ -365,6 +370,7 @@
365
370
  "36b58dbdc26e51abc05a60872c4582faeebd67e9fe5626a3933467ba07c68dc4",
366
371
  "40237cd5259dbcf7c490f130e844a9e32eea48a8edabd50800edee5029bf95a4",
367
372
  "4213143689f7e7fe62ab8743c256a45492fafffc1757d64901b7b02725aee5c3",
373
+ "437659470cb978840f503c7d4a2af75c78beb2752cc3eed413bb33de346bd561",
368
374
  "472d2ad853c96651324aa9a3de00cb61c0cecacb389e850ab1b380d535d22922",
369
375
  "49d6e024b343dd77e4c0cea0e513e40fe679a7a8192953190d042b3a21bbf69c",
370
376
  "5536f8391ec4442f1b793dba8dd5d7820d45f4b6dadf1111cbac9d54469a01ca",
@@ -384,6 +390,7 @@
384
390
  "6e2b54571bfbc1fab571b81564fb0625ed83333fc12277c473fdf5a231f7d40c",
385
391
  "6e7228610628fc4bca31dc0b0aaf5eb4445e1f123f2c54bd13921c5cd01129fe",
386
392
  "75cecbce4d1f4adbba608cef498ae7e421ea75512c6bf9282b66cd94d821f672",
393
+ "78be1509ca8cf797f8c6e790d0d49ab608e50e0b828916c8c6ee790165ce792b",
387
394
  "7f451e48e391643b34f6249244bc47d93d6303875e7b092771e3af0718343bc1",
388
395
  "7fb3ceaf835230e1a75e510b046f4fe26e79815760354bec1949fa55515c6316",
389
396
  "83e9de10d3c439af17dc4d7a603109d35136c5c8bd468ab2be962e1fad064c3c",
@@ -407,6 +414,7 @@
407
414
  "db8d921aabf0d04811b55465a3757c62c4b39631aebaf6eecc994ca241cfbe56",
408
415
  "e25da746dc37b2e82c658a28d9481fd69741151045683eee2119582ddaf731cd",
409
416
  "e47a2ce766ae81fa0870e0a8dfbfe737311de69ce620e92217abbe4c532e3c8b",
417
+ "e67d3662836adf13c2e7177553bba6e7695484bd40438e8188cb9dd6d956cd2f",
410
418
  "e929f76c15349f1b9bc1e84e0a3e9e48e916168d79270e0f4a36ea8eab9b5388",
411
419
  "ebc693658da0b260c876f29cd6dfc2b12ae6690e3532622fa9c1cd4481162bd8",
412
420
  "f2243cc4150e225961825f6097b0cf38166c291dcf58127516ceee841f7ed4c8",
@@ -829,6 +837,8 @@
829
837
  "4.3.3",
830
838
  "4.3.30",
831
839
  "4.3.31",
840
+ "4.3.32",
841
+ "4.3.33",
832
842
  "4.3.4",
833
843
  "4.3.5",
834
844
  "4.3.6",
@@ -363,6 +363,8 @@ class BringInTests(unittest.TestCase):
363
363
  self.env = {key: value for key, value in os.environ.items()
364
364
  if not key.startswith("GIT_")}
365
365
  self.env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=str(self.root / "no-global"),
366
+ # A commit's detached automatic maintenance would still write into .git while a test copies it.
367
+ GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="maintenance.auto", GIT_CONFIG_VALUE_0="false",
366
368
  GIT_AUTHOR_NAME="Fixture", GIT_AUTHOR_EMAIL="fixture@example.invalid",
367
369
  GIT_COMMITTER_NAME="Fixture", GIT_COMMITTER_EMAIL="fixture@example.invalid",
368
370
  LC_ALL="C", GIT_EDITOR=":", GIT_MERGE_AUTOEDIT="no")
@@ -415,6 +417,14 @@ class BringInTests(unittest.TestCase):
415
417
  path.write_bytes(("#!/bin/sh\n" + body).encode("utf-8"))
416
418
  path.chmod(0o755)
417
419
 
420
+ def test_fixture_commit_starts_no_automatic_maintenance(self):
421
+ # Its detached child would still write .git/objects/maintenance.lock while copytree or cleanup reads the tree.
422
+ trace = self.root / "trace.json"
423
+ self.env["GIT_TRACE2_EVENT"] = str(trace)
424
+ self.commit("traced", "traced\n")
425
+ children = [line for line in trace.read_text(encoding="utf-8").splitlines() if '"event":"child_start"' in line]
426
+ self.assertEqual(children, [])
427
+
418
428
  def test_fast_forward_and_paths_with_spaces(self):
419
429
  self.cli()
420
430
  self.assertEqual(self.g("rev-parse", "HEAD").strip(), self.target)