claude-dev-env 8.98.7 → 8.98.8

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.
@@ -72,6 +72,7 @@ const HOST_DEFINITIONS = Object.freeze({
72
72
  ),
73
73
  installVerb: 'install',
74
74
  uninstallVerb: 'uninstall',
75
+ updateVerb: 'update',
75
76
  }),
76
77
  codex: Object.freeze({
77
78
  executable: 'codex',
@@ -79,18 +80,23 @@ const HOST_DEFINITIONS = Object.freeze({
79
80
  homeVariable: 'CODEX_HOME',
80
81
  installVerb: 'add',
81
82
  uninstallVerb: 'remove',
83
+ updateVerb: null,
82
84
  }),
83
85
  });
84
86
 
85
87
  /**
86
88
  * Read the marketplace and plugin commands one host installs a set of plugins with.
87
89
  *
88
- * Every plugin in the set comes from one marketplace. Removing a marketplace
89
- * uninstalls each plugin it supplied, so the plan removes and adds that
90
- * marketplace once for the whole set. The removal commands run first and take
91
- * out each installed plugin, its cached versions, and the marketplace clone,
92
- * so the install commands that follow fetch the newest published version into
93
- * an empty slot.
90
+ * Every plugin in the set comes from one marketplace. A host with an update
91
+ * verb gets refresh commands: the marketplace add and plugin install leave an
92
+ * existing entry in place, and the update commands then pull the newest
93
+ * published version over it, so an installed plugin stays installed through
94
+ * the whole run. When any refresh command fails, or the host has no update
95
+ * verb, the removal commands run and take out each installed plugin, its
96
+ * cached versions, and the marketplace clone, and the install commands that
97
+ * follow fetch the newest published version into an empty slot. Removing a
98
+ * marketplace uninstalls each plugin it supplied, so the plan removes and adds
99
+ * that marketplace once for the whole set.
94
100
  *
95
101
  * Claude Code and Codex publish a marketplace under different plugin verbs, so
96
102
  * the install verb belongs to the host and the marketplace and identifier
@@ -102,6 +108,7 @@ const HOST_DEFINITIONS = Object.freeze({
102
108
  * @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
103
109
  * @param {string} host A host every spec names, `claude` or `codex`.
104
110
  * @returns {{executable: string, executableVariable: string, homeVariable: string,
111
+ * refreshCommands: ReadonlyArray<ReadonlyArray<string>>,
105
112
  * removalCommands: ReadonlyArray<ReadonlyArray<string>>,
106
113
  * commands: ReadonlyArray<ReadonlyArray<string>>}} The host's install plan.
107
114
  */
@@ -126,18 +133,28 @@ export function marketplacePluginsPlan(allSpecs, host) {
126
133
  }
127
134
  const allSparsePaths = [...new Set(allSpecs.flatMap(eachSpec => eachSpec.sparsePaths))];
128
135
  const sparseArguments = allSparsePaths.length > 0 ? ['--sparse', ...allSparsePaths] : [];
136
+ const marketplaceAddCommand = ['plugin', 'marketplace', 'add', firstSpec.marketplaceRepository, ...sparseArguments];
137
+ const allPluginInstallCommands = allSpecs.map(
138
+ eachSpec => ['plugin', hostDefinition.installVerb, eachSpec.pluginIdentifier],
139
+ );
140
+ const refreshCommands = hostDefinition.updateVerb
141
+ ? [
142
+ marketplaceAddCommand,
143
+ ['plugin', 'marketplace', hostDefinition.updateVerb, firstSpec.marketplaceName],
144
+ ...allPluginInstallCommands,
145
+ ...allSpecs.map(eachSpec => ['plugin', hostDefinition.updateVerb, eachSpec.pluginIdentifier]),
146
+ ]
147
+ : [];
129
148
  return {
130
149
  executable: hostDefinition.executable,
131
150
  executableVariable: hostDefinition.executableVariable,
132
151
  homeVariable: hostDefinition.homeVariable,
152
+ refreshCommands,
133
153
  removalCommands: [
134
154
  ...allSpecs.map(eachSpec => ['plugin', hostDefinition.uninstallVerb, eachSpec.pluginIdentifier]),
135
155
  ['plugin', 'marketplace', 'remove', firstSpec.marketplaceName],
136
156
  ],
137
- commands: [
138
- ['plugin', 'marketplace', 'add', firstSpec.marketplaceRepository, ...sparseArguments],
139
- ...allSpecs.map(eachSpec => ['plugin', hostDefinition.installVerb, eachSpec.pluginIdentifier]),
140
- ],
157
+ commands: [marketplaceAddCommand, ...allPluginInstallCommands],
141
158
  };
142
159
  }
143
160
 
@@ -280,18 +297,49 @@ function lockedFolderDetail(allSpecs, host, homeDirectory, errorText, findLocker
280
297
  }).join('');
281
298
  }
282
299
 
300
+ function refreshInPlace(plan, executable, commandEnvironment, runCommand) {
301
+ for (const commandArguments of plan.refreshCommands) {
302
+ const outcome = runCommand(executable, [...commandArguments], {
303
+ environment: commandEnvironment,
304
+ });
305
+ if (namesAnAbsentCommand(outcome)) return 'absent';
306
+ if (outcome.status !== 0 || outcome.error) return 'failed';
307
+ }
308
+ return 'refreshed';
309
+ }
310
+
311
+ function removeOrphanedVersionsForHost(allSpecs, host, homeDirectory) {
312
+ const hostDefinition = HOST_DEFINITIONS[host];
313
+ if (!hostDefinition.orphanedVersionsDirectory || !homeDirectory) return;
314
+ for (const eachSpec of allSpecs) {
315
+ removeOrphanedPluginVersions(hostDefinition.orphanedVersionsDirectory(homeDirectory, eachSpec));
316
+ }
317
+ }
318
+
283
319
  function installForHost(allSpecs, host, homeDirectory, environment, runCommand, findLockers) {
284
320
  const plan = marketplacePluginsPlan(allSpecs, host);
285
321
  const executable = environment[plan.executableVariable] || plan.executable;
286
322
  const commandEnvironment = { [plan.homeVariable]: homeDirectory };
323
+ const staleCloneDirectory = HOST_DEFINITIONS[host].staleCloneDirectory;
324
+ if (staleCloneDirectory && homeDirectory) {
325
+ removeStaleMarketplaceClone(staleCloneDirectory(homeDirectory, allSpecs[0]));
326
+ }
327
+ if (plan.refreshCommands.length > 0) {
328
+ const refreshOutcome = refreshInPlace(plan, executable, commandEnvironment, runCommand);
329
+ if (refreshOutcome === 'absent') return absentHostOutcome(allSpecs, host, executable);
330
+ if (refreshOutcome === 'refreshed') {
331
+ removeOrphanedVersionsForHost(allSpecs, host, homeDirectory);
332
+ return { host, executable, status: 'installed', warning: null };
333
+ }
334
+ }
287
335
  for (const commandArguments of plan.removalCommands) {
288
336
  const outcome = runCommand(executable, [...commandArguments], {
289
337
  environment: commandEnvironment,
290
338
  });
291
339
  if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
292
340
  }
293
- if (HOST_DEFINITIONS[host].staleCloneDirectory && homeDirectory) {
294
- removeStaleMarketplaceClone(HOST_DEFINITIONS[host].staleCloneDirectory(homeDirectory, allSpecs[0]));
341
+ if (staleCloneDirectory && homeDirectory) {
342
+ removeStaleMarketplaceClone(staleCloneDirectory(homeDirectory, allSpecs[0]));
295
343
  }
296
344
  for (const commandArguments of plan.commands) {
297
345
  const outcome = runCommand(executable, [...commandArguments], {
@@ -309,12 +357,7 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
309
357
  };
310
358
  }
311
359
  }
312
- const hostDefinition = HOST_DEFINITIONS[host];
313
- if (hostDefinition.orphanedVersionsDirectory && homeDirectory) {
314
- for (const eachSpec of allSpecs) {
315
- removeOrphanedPluginVersions(hostDefinition.orphanedVersionsDirectory(homeDirectory, eachSpec));
316
- }
317
- }
360
+ removeOrphanedVersionsForHost(allSpecs, host, homeDirectory);
318
361
  return { host, executable, status: 'installed', warning: null };
319
362
  }
320
363
 
@@ -338,10 +381,17 @@ export function restartNotice(allSpecs, allInstalledHosts) {
338
381
  /**
339
382
  * Install a set of plugins from one marketplace into each host's own home.
340
383
  *
341
- * Each host first removes every plugin in the set and then the marketplace,
342
- * so an older version never survives beside the new one. A removal that finds
384
+ * Claude Code refreshes in place: it adds the marketplace and installs each
385
+ * plugin, both of which leave an existing entry alone, then updates the
386
+ * marketplace and each plugin. An installed plugin stays installed and enabled
387
+ * through the run, so a session that starts mid-run still loads it, and a
388
+ * failed network step leaves the prior version in place. When a refresh
389
+ * command fails, such as a marketplace add whose sparse paths changed, the
390
+ * host falls back to the clean install Codex always runs: it removes every
391
+ * plugin in the set and then the marketplace, so an older version never
392
+ * survives beside the new one, and adds them again. A removal that finds
343
393
  * nothing to remove exits non-zero on a fresh home, and the install goes on.
344
- * Before the Claude marketplace add, the install deletes the half-made clone
394
+ * Before each Claude marketplace add, the install deletes the half-made clone
345
395
  * folder an interrupted earlier add left, so the add starts in an empty slot.
346
396
  *
347
397
  * Each host is one member of the batch. A host without its command-line tool
@@ -68,10 +68,10 @@ test('each host installs into the managed root this run wrote', () => {
68
68
  const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, runner);
69
69
  assert.equal(outcome.status, 'installed');
70
70
  assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
71
- ['claude', 'plugin', 'uninstall', PSTACK_PLUGIN_IDENTIFIER],
72
- ['claude', 'plugin', 'marketplace', 'remove', 'pstack-claude'],
73
71
  ['claude', 'plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
72
+ ['claude', 'plugin', 'marketplace', 'update', 'pstack-claude'],
74
73
  ['claude', 'plugin', 'install', PSTACK_PLUGIN_IDENTIFIER],
74
+ ['claude', 'plugin', 'update', PSTACK_PLUGIN_IDENTIFIER],
75
75
  ['codex', 'plugin', 'remove', PSTACK_PLUGIN_IDENTIFIER],
76
76
  ['codex', 'plugin', 'marketplace', 'remove', 'pstack-claude'],
77
77
  ['codex', 'plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
@@ -81,7 +81,43 @@ test('each host installs into the managed root this run wrote', () => {
81
81
  assert.equal(runner.calls[4].environment.CODEX_HOME, ROOTS.codexHome);
82
82
  });
83
83
 
84
- test('each host removes the installed plugin and marketplace before it installs', () => {
84
+ test('the Claude plan refreshes in place and Codex has no refresh commands', () => {
85
+ assert.deepEqual(marketplacePluginsPlan([PSTACK_PLUGIN_SPEC], 'claude').refreshCommands, [
86
+ ['plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
87
+ ['plugin', 'marketplace', 'update', 'pstack-claude'],
88
+ ['plugin', 'install', PSTACK_PLUGIN_IDENTIFIER],
89
+ ['plugin', 'update', PSTACK_PLUGIN_IDENTIFIER],
90
+ ]);
91
+ assert.deepEqual(marketplacePluginsPlan([PSTACK_PLUGIN_SPEC], 'codex').refreshCommands, []);
92
+ });
93
+
94
+ test('a Claude refresh that passes never uninstalls the plugin or removes its marketplace', () => {
95
+ const runner = recordingRunner();
96
+ const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], { ...ROOTS, hosts: ['claude'] }, runner);
97
+ assert.equal(outcome.status, 'installed');
98
+ const allRemovalCalls = runner.calls.filter(call => (
99
+ call.commandArguments.includes('uninstall') || call.commandArguments.includes('remove')
100
+ ));
101
+ assert.deepEqual(allRemovalCalls, []);
102
+ });
103
+
104
+ test('a failed Claude refresh falls back to removing and adding the marketplace again', () => {
105
+ const runner = recordingRunner((executable, commandArguments) => (
106
+ commandArguments[2] === 'add' && runner.calls.length === 1
107
+ ? { status: 1, stderr: "its source doesn't match its extraKnownMarketplaces entry" }
108
+ : { status: 0, stderr: '' }));
109
+ const outcome = installMarketplacePlugins([USAGE_WRAPUP_PLUGIN_SPEC], { ...ROOTS, hosts: ['claude'] }, runner);
110
+ assert.equal(outcome.status, 'installed');
111
+ assert.deepEqual(runner.calls.map(call => call.commandArguments), [
112
+ ['plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
113
+ ['plugin', 'uninstall', 'usage-wrapup@claude-dev-env'],
114
+ ['plugin', 'marketplace', 'remove', 'claude-dev-env'],
115
+ ['plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
116
+ ['plugin', 'install', 'usage-wrapup@claude-dev-env'],
117
+ ]);
118
+ });
119
+
120
+ test('each host removes the installed plugin and marketplace before a clean install', () => {
85
121
  assert.deepEqual(marketplacePluginsPlan([PSTACK_PLUGIN_SPEC], 'claude').removalCommands, [
86
122
  ['plugin', 'uninstall', PSTACK_PLUGIN_IDENTIFIER],
87
123
  ['plugin', 'marketplace', 'remove', 'pstack-claude'],
@@ -95,6 +131,7 @@ test('each host removes the installed plugin and marketplace before it installs'
95
131
  test('a removal that finds nothing installed still lets the install run', () => {
96
132
  const runner = recordingRunner((executable, commandArguments) => (
97
133
  commandArguments.includes('uninstall') || commandArguments.includes('remove')
134
+ || commandArguments.includes('update')
98
135
  ? { status: 1, stderr: 'not installed' }
99
136
  : { status: 0, stderr: '' }));
100
137
  const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], { ...ROOTS, hosts: ['claude'] }, runner);
@@ -267,10 +304,10 @@ test('installing usage-wrapup runs only the Claude commands, inside the managed
267
304
  assert.equal(outcome.status, 'installed');
268
305
  assert.deepEqual(outcome.hosts.map(host => host.host), ['claude']);
269
306
  assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
270
- ['claude', 'plugin', 'uninstall', 'usage-wrapup@claude-dev-env'],
271
- ['claude', 'plugin', 'marketplace', 'remove', 'claude-dev-env'],
272
307
  ['claude', 'plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
308
+ ['claude', 'plugin', 'marketplace', 'update', 'claude-dev-env'],
273
309
  ['claude', 'plugin', 'install', 'usage-wrapup@claude-dev-env'],
310
+ ['claude', 'plugin', 'update', 'usage-wrapup@claude-dev-env'],
274
311
  ]);
275
312
  for (const call of runner.calls) {
276
313
  assert.deepEqual(call.environment, { CLAUDE_CONFIG_DIR: ROOTS.claudeRoot });
@@ -319,21 +356,21 @@ for (const [spec, packageFolder] of [
319
356
  });
320
357
  }
321
358
 
322
- test('should remove and add the shared marketplace once when installing usage-wrapup and subagent-models together', () => {
359
+ test('should add and update the shared marketplace once when installing usage-wrapup and subagent-models together', () => {
323
360
  const runner = recordingRunner();
324
361
  const outcome = installMarketplacePlugins([USAGE_WRAPUP_PLUGIN_SPEC, SUBAGENT_MODELS_PLUGIN_SPEC], ROOTS, runner);
325
362
  assert.equal(outcome.status, 'installed');
326
363
  assert.deepEqual(outcome.hosts.map(host => host.host), ['claude']);
327
364
  assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
328
- ['claude', 'plugin', 'uninstall', 'usage-wrapup@claude-dev-env'],
329
- ['claude', 'plugin', 'uninstall', 'subagent-models@claude-dev-env'],
330
- ['claude', 'plugin', 'marketplace', 'remove', 'claude-dev-env'],
331
365
  [
332
366
  'claude', 'plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env',
333
367
  '--sparse', '.claude-plugin', 'packages/usage-wrapup', 'packages/subagent-models',
334
368
  ],
369
+ ['claude', 'plugin', 'marketplace', 'update', 'claude-dev-env'],
335
370
  ['claude', 'plugin', 'install', 'usage-wrapup@claude-dev-env'],
336
371
  ['claude', 'plugin', 'install', 'subagent-models@claude-dev-env'],
372
+ ['claude', 'plugin', 'update', 'usage-wrapup@claude-dev-env'],
373
+ ['claude', 'plugin', 'update', 'subagent-models@claude-dev-env'],
337
374
  ]);
338
375
  });
339
376
 
@@ -3148,10 +3148,10 @@ test('a full install adds the pstack marketplace and plugin on both hosts', t =>
3148
3148
  const installerOutput = runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3149
3149
 
3150
3150
  assert.deepEqual(sandbox.recordedCommands(), [
3151
- 'claude plugin uninstall pstack@pstack-claude',
3152
- 'claude plugin marketplace remove pstack-claude',
3153
3151
  'claude plugin marketplace add jl-cmd/pstack-claude',
3152
+ 'claude plugin marketplace update pstack-claude',
3154
3153
  'claude plugin install pstack@pstack-claude',
3154
+ 'claude plugin update pstack@pstack-claude',
3155
3155
  'codex plugin remove pstack@pstack-claude',
3156
3156
  'codex plugin marketplace remove pstack-claude',
3157
3157
  'codex plugin marketplace add jl-cmd/pstack-claude',
@@ -3190,10 +3190,10 @@ test('a full install adds DeepStack for Claude, Codex, and Cursor', t => {
3190
3190
  });
3191
3191
 
3192
3192
  assert.deepEqual(sandbox.recordedCommands(), [
3193
- 'claude plugin uninstall deepstack@DeepStack',
3194
- 'claude plugin marketplace remove DeepStack',
3195
3193
  'claude plugin marketplace add jl-cmd-projects/DeepStack',
3194
+ 'claude plugin marketplace update DeepStack',
3196
3195
  'claude plugin install deepstack@DeepStack',
3196
+ 'claude plugin update deepstack@DeepStack',
3197
3197
  'codex plugin remove deepstack@DeepStack',
3198
3198
  'codex plugin marketplace remove DeepStack',
3199
3199
  'codex plugin marketplace add jl-cmd-projects/DeepStack',
@@ -3407,10 +3407,10 @@ test('a pstack install after a --no-pstack install seeds the Codex model sheet',
3407
3407
  });
3408
3408
 
3409
3409
  const USAGE_WRAPUP_CLAUDE_COMMANDS = Object.freeze([
3410
- 'claude plugin uninstall usage-wrapup@claude-dev-env',
3411
- 'claude plugin marketplace remove claude-dev-env',
3412
3410
  'claude plugin marketplace add jl-cmd-projects/claude-dev-env --sparse .claude-plugin packages/usage-wrapup',
3411
+ 'claude plugin marketplace update claude-dev-env',
3413
3412
  'claude plugin install usage-wrapup@claude-dev-env',
3413
+ 'claude plugin update usage-wrapup@claude-dev-env',
3414
3414
  ]);
3415
3415
 
3416
3416
  function usageWrapupCommands(allRecordedCommands) {
@@ -3485,12 +3485,12 @@ test('the help output names the usage-wrapup opt-out beside the pstack one', ()
3485
3485
  });
3486
3486
 
3487
3487
  const REPOSITORY_PLUGIN_CLAUDE_COMMANDS = Object.freeze([
3488
- 'claude plugin uninstall usage-wrapup@claude-dev-env',
3489
- 'claude plugin uninstall subagent-models@claude-dev-env',
3490
- 'claude plugin marketplace remove claude-dev-env',
3491
3488
  'claude plugin marketplace add jl-cmd-projects/claude-dev-env --sparse .claude-plugin packages/usage-wrapup packages/subagent-models',
3489
+ 'claude plugin marketplace update claude-dev-env',
3492
3490
  'claude plugin install usage-wrapup@claude-dev-env',
3493
3491
  'claude plugin install subagent-models@claude-dev-env',
3492
+ 'claude plugin update usage-wrapup@claude-dev-env',
3493
+ 'claude plugin update subagent-models@claude-dev-env',
3494
3494
  ]);
3495
3495
 
3496
3496
  test('should install subagent-models beside usage-wrapup with one marketplace add on a full install', t => {
@@ -18,7 +18,7 @@ python scripts/account_broker.py check --product codex
18
18
  printf "%s" "prompt" | python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p
19
19
  ```
20
20
 
21
- `accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `profile`, `home`, `reason`, `tier`, and `resets_at`. `profile` is the name of the profile whose home the account uses, such as `editor` for a home at `~/.claude-profiles/editor`. It is null for the main account, a Codex account, a home outside the profiles root, a home whose folder name is not a valid profile name such as `main`, and a wait decision. `--spent` takes an account's name or its profile. The account order file matches the name, command, or launcher, and resume affinity stores the command, or the name for an account without one. When a mark is one account's name and another account's profile, the name wins. Every mark is read before the first one is saved, so an unreadable mark saves none. `accounts` and the `choose` account list carry the same `profile` field. A wait decision has no account or home and includes the soonest reset in UTC. An account counts as free at the latest reset of the windows that hold it, and one hour out when one of those windows has no reset time. A failed meter read reuses the account's last good reading when that reading is 30 minutes old or less, and otherwise excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. A mark splits at its last colon, so an absent name that contains a colon, such as `C:\bin\old.cmd`, takes an explicit reset, such as `C:\bin\old.cmd:1800000000`. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. Meter reads younger than 60 seconds are reused.
21
+ `accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `profile`, `home`, `reason`, `tier`, and `resets_at`. `profile` is the name of the profile whose home the account uses, such as `editor` for a home at `~/.claude-profiles/editor`. It is null for the main account, a Codex account, a home outside the profiles root, a home whose folder name is not a valid profile name such as `main`, and a wait decision. `--spent` takes an account's name or its profile. The account order file matches the name, command, or launcher, and resume affinity stores the command, or the name for an account without one. When a mark is one account's name and another account's profile, the name wins. Every mark is read before the first one is saved, so an unreadable mark saves none. `accounts` and the `choose` account list carry the same `profile` field. A wait decision has no account or home and includes the soonest reset in UTC. An account counts as free at the latest reset of the windows that hold it, and one hour out when one of those windows has no reset time. A failed meter read reuses the account's last good reading when that reading is 30 minutes old or less, and otherwise excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. A mark splits at its last colon, so an absent name that contains a colon, such as `C:\bin\old.cmd`, takes an explicit reset, such as `C:\bin\old.cmd:1800000000`. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. `default` is the name `choose` gives the Codex home when no roster is configured, so while a Codex roster exists `choose` neither saves a `--spent default` mark nor waits on a stored one. Meter reads younger than 60 seconds are reused.
22
22
 
23
23
  `check` prints nothing and exits 3 while every account is below its floor. `run` sets `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. A Claude job also starts without the parent session's variables, such as `CLAUDE_CODE_SESSION_ID` and `CLAUDECODE`, so a child started from inside a Claude Code session runs as its own session. `ALL_PARENT_CLAUDE_SESSION_VARIABLES` lists them. It replays the same stdin bytes when a usage limit or start failure leads to another account. Send the prompt on stdin. When the command resolves to a `.cmd` or `.bat` file, the broker refuses an argument that holds a cmd.exe metacharacter and records `start_failed`. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.98.7",
3
+ "version": "8.98.8",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,9 +8,11 @@
8
8
  a remote, such as a read-only mirror kept current by a scheduled task.
9
9
 
10
10
  Safety rules:
11
- - Only runs when the checked-out branch is the named branch
11
+ - Only runs when the checked-out branch is the named branch, unless
12
+ -Mirror is passed
12
13
  - Only runs when there are no tracked changes (untracked files are fine),
13
- unless -StashDirty is passed
14
+ unless -StashDirty is passed. A -Mirror return stashes the tree the old
15
+ checkout left whatever -StashDirty says
14
16
  - Uses merge --ff-only (never force, never hard reset, never clean)
15
17
  - When the fast-forward fails because the files were already copied in,
16
18
  moves HEAD with a mixed reset, which writes no files. It keeps that move
@@ -42,6 +44,20 @@
42
44
  missing; when that pop also fails, the log names the stash entry so the
43
45
  operator can recover it by hand.
44
46
 
47
+ .PARAMETER Mirror
48
+ Marks the tree as a mirror that must always sit on the named branch. With
49
+ the switch, a checkout found on another branch or a detached HEAD is moved
50
+ back: HEAD points at the local named branch again and a mixed reset moves
51
+ the index there. Every path that still holds the old checkout's content,
52
+ including files only the old checkout tracked, is stashed as "mirror
53
+ return <yyyy-MM-dd>: tree of <checkout> at <tip>", which puts the named
54
+ branch's files back. This stash runs with or without -StashDirty, because
55
+ a mirror's tree belongs to the named branch. The log names the checkout
56
+ and tip it left. A checkout with staged changes is skipped, so no staged
57
+ content is lost. When a step of the return fails, HEAD and the index go
58
+ back to the old checkout and the run exits 1. Without the switch, any
59
+ other checkout is skipped.
60
+
45
61
  .PARAMETER LogPath
46
62
  Absolute path to the run log (default: $HOME\.claude\logs\sync-repo-main.log).
47
63
  #>
@@ -52,6 +68,7 @@ param(
52
68
  [string]$Remote = 'origin',
53
69
  [string]$Branch = 'main',
54
70
  [switch]$StashDirty,
71
+ [switch]$Mirror,
55
72
  [string]$LogPath = (Join-Path $HOME '.claude\logs\sync-repo-main.log')
56
73
  )
57
74
 
@@ -131,6 +148,84 @@ function Invoke-GitOrExit {
131
148
  return $result.Output
132
149
  }
133
150
 
151
+ function Invoke-MirrorReturn {
152
+ <#
153
+ Moves a mirror found on another branch or a detached HEAD back to the
154
+ named branch. HEAD and the index move first, then every path where the
155
+ tree still holds the old checkout's content is stashed, including files
156
+ only the old checkout tracked. Any failure puts HEAD and the index back
157
+ on the old checkout, so the next run meets the same state and tries again.
158
+ #>
159
+ param(
160
+ [Parameter(Mandatory)]
161
+ [AllowEmptyString()]
162
+ [string]$LeftBranch
163
+ )
164
+ $leftCheckoutName = if ($LeftBranch) { "branch '$LeftBranch'" } else { 'detached HEAD' }
165
+ $stagedResult = Invoke-Git -Arguments @('diff', '--cached', '--quiet')
166
+ if ($stagedResult.ExitCode -ne 0) {
167
+ Write-SyncLog -Level 'SKIP' -Message "mirror return refused: $leftCheckoutName has staged changes"
168
+ exit 3
169
+ }
170
+ $leftTip = Invoke-GitOrExit -Arguments @('rev-parse', 'HEAD') `
171
+ -FailureMessage 'cannot read the tip of the checkout being left' -FailureExitCode 2
172
+ $null = Invoke-GitOrExit -Arguments @('rev-parse', '--verify', '-q', "refs/heads/$Branch") `
173
+ -FailureMessage "mirror has no local $Branch branch to return to" -FailureExitCode 2
174
+ $leftOnlyResult = Invoke-Git -Arguments @('diff', '--name-only', '--no-renames', '-z', '--diff-filter=A', "refs/heads/$Branch", $leftTip)
175
+ if ($leftOnlyResult.ExitCode -ne 0) {
176
+ Write-SyncLog -Level 'ERROR' -Message "mirror return skipped: cannot list files only $leftCheckoutName tracks: $($leftOnlyResult.Output)"
177
+ exit 1
178
+ }
179
+ $null = Invoke-GitOrExit -Arguments @('symbolic-ref', 'HEAD', "refs/heads/$Branch") `
180
+ -FailureMessage "cannot point HEAD at $Branch" -FailureExitCode 1
181
+
182
+ $resetResult = Invoke-Git -Arguments @('reset', '-q')
183
+ $changedResult = if ($resetResult.ExitCode -eq 0) { Invoke-Git -Arguments @('diff', '--name-only', '--no-renames', '-z') } else { $resetResult }
184
+ $failure = if ($resetResult.ExitCode -ne 0) { "mixed reset onto $Branch failed: $($resetResult.Output)" }
185
+ elseif ($changedResult.ExitCode -ne 0) { "cannot list the tree left by $leftCheckoutName`: $($changedResult.Output)" }
186
+ $leftTreeNote = 'the tree already matched'
187
+ if (-not $failure) {
188
+ $allLeftPaths = [System.Collections.Generic.List[string]]::new()
189
+ foreach ($changedPath in Get-NulSeparatedPaths -Output $changedResult.Output) { $allLeftPaths.Add($changedPath) }
190
+ foreach ($leftOnlyPath in Get-NulSeparatedPaths -Output $leftOnlyResult.Output) {
191
+ if (Test-Path -LiteralPath (Join-Path $RepoPath $leftOnlyPath)) { $allLeftPaths.Add($leftOnlyPath) }
192
+ }
193
+ if ($allLeftPaths.Count -gt 0) {
194
+ $leftTreeStashMessage = 'mirror return {0}: tree of {1} at {2}' -f (Get-Date -Format 'yyyy-MM-dd'), $leftCheckoutName, $leftTip
195
+ $pathspecFile = $null
196
+ try {
197
+ $pathspecFile = New-TemporaryFile
198
+ $pathspecText = (($allLeftPaths | Select-Object -Unique | ForEach-Object { ":(literal)$_" }) -join "`0") + "`0"
199
+ [System.IO.File]::WriteAllText($pathspecFile.FullName, $pathspecText, [System.Text.UTF8Encoding]::new($false))
200
+ $stashResult = Invoke-Git -Arguments @('stash', 'push', '--all', '-m', $leftTreeStashMessage, "--pathspec-from-file=$($pathspecFile.FullName)", '--pathspec-file-nul')
201
+ }
202
+ catch {
203
+ $stashResult = [pscustomobject]@{ ExitCode = 1; Output = $_.Exception.Message }
204
+ }
205
+ finally {
206
+ if ($pathspecFile) {
207
+ Remove-Item -LiteralPath $pathspecFile.FullName -Force -ErrorAction SilentlyContinue
208
+ }
209
+ }
210
+ if ($stashResult.ExitCode -ne 0) {
211
+ $failure = "stashing the tree of $leftCheckoutName failed: $($stashResult.Output)"
212
+ }
213
+ else {
214
+ $leftTreeNote = "its tree is stashed as '$leftTreeStashMessage'"
215
+ }
216
+ }
217
+ }
218
+ if ($failure) {
219
+ $restoreArguments = if ($LeftBranch) { @('symbolic-ref', 'HEAD', "refs/heads/$LeftBranch") } else { @('update-ref', '--no-deref', 'HEAD', $leftTip) }
220
+ $restoreResult = Invoke-Git -Arguments $restoreArguments
221
+ $indexResult = if ($resetResult.ExitCode -eq 0) { Invoke-Git -Arguments @('reset', '-q') } else { $restoreResult }
222
+ $restoreNote = if ($restoreResult.ExitCode -eq 0 -and $indexResult.ExitCode -eq 0) { "HEAD and index are back on $leftCheckoutName at $leftTip" } else { "putting HEAD back on $leftCheckoutName at $leftTip also failed: $($restoreResult.Output) $($indexResult.Output)" }
223
+ Write-SyncLog -Level 'ERROR' -Message "mirror return failed, $failure; $restoreNote"
224
+ exit 1
225
+ }
226
+ Write-SyncLog -Level 'MIRROR' -Message "left $leftCheckoutName at $leftTip; HEAD is on $Branch and $leftTreeNote"
227
+ }
228
+
134
229
  function Get-NulSeparatedPaths {
135
230
  param(
136
231
  [Parameter(Mandatory)]
@@ -229,8 +324,11 @@ try {
229
324
  $currentBranch = Invoke-GitOrExit -Arguments @('branch', '--show-current') `
230
325
  -FailureMessage 'cannot read current branch' -FailureExitCode 2
231
326
  if ($currentBranch -ne $Branch) {
232
- Write-SyncLog -Level 'SKIP' -Message "checkout is '$currentBranch', not '$Branch' (remote=$Remote)"
233
- exit 3
327
+ if (-not $Mirror) {
328
+ Write-SyncLog -Level 'SKIP' -Message "checkout is '$currentBranch', not '$Branch' (remote=$Remote)"
329
+ exit 3
330
+ }
331
+ Invoke-MirrorReturn -LeftBranch $currentBranch
234
332
  }
235
333
 
236
334
  $statusOutput = Invoke-GitOrExit -Arguments @('status', '--porcelain') `
@@ -168,6 +168,11 @@ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime) -
168
168
  return until
169
169
 
170
170
 
171
+ def _is_no_roster_name_beside_a_roster(product: Product, name: str, all_readings: Sequence[Reading]) -> bool:
172
+ all_roster_names = {each_reading.account.name for each_reading in all_readings}
173
+ return product is Product.CODEX and bool(all_roster_names) and name == NO_ROSTER_ACCOUNT_NAME and name not in all_roster_names
174
+
175
+
171
176
  def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], all_state: Mapping[str, object], now: datetime) -> dict[str, datetime]:
172
177
  marks = all_state.get("spent") if isinstance(all_state.get("spent"), dict) else {}
173
178
  all_roster_names = {each_reading.account.name for each_reading in all_readings}
@@ -178,7 +183,7 @@ def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], al
178
183
  continue
179
184
  name = each_key[len(prefix):-len(placeholder_suffix)]
180
185
  reset = _stored_reset(each_raw, now)
181
- if name not in all_roster_names and reset is not None:
186
+ if name not in all_roster_names and reset is not None and not _is_no_roster_name_beside_a_roster(product, name, all_readings):
182
187
  all_resets_by_name[name] = reset
183
188
  return all_resets_by_name
184
189
 
@@ -395,13 +400,16 @@ def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Readi
395
400
  all_accounts_by_profile = {each_reading.account.profile: each_reading for each_reading in all_readings if each_reading.account.profile}
396
401
  all_accounts_by_name = all_accounts_by_profile | {each_reading.account.name: each_reading for each_reading in all_readings}
397
402
  all_parsed_marks = [_parse_spent_mark(all_accounts_by_name, each_mark, product) for each_mark in all_marks]
398
- for each_reading, each_reset in all_parsed_marks:
403
+ all_saved_marks = [each_mark for each_mark in all_parsed_marks if not _is_no_roster_name_beside_a_roster(product, each_mark[0].account.name, all_readings)]
404
+ for each_reading, each_reset in all_saved_marks:
399
405
  _record_spent(all_state, each_reading, now, each_reset)
400
- if all_parsed_marks:
406
+ if all_saved_marks:
401
407
  _save_state(broker_state_path(), all_state)
402
408
  for each_reading, _ in all_parsed_marks:
403
409
  name = each_reading.account.name
404
- if name not in all_accounts_by_name and (all_accounts_by_name or name != NO_ROSTER_ACCOUNT_NAME):
410
+ if _is_no_roster_name_beside_a_roster(product, name, all_readings):
411
+ print(f"warning: spent mark names {name}, the home used only with no roster, so it is outside the roster; choose ignores it", file=sys.stderr)
412
+ elif name not in all_accounts_by_name and (all_accounts_by_name or name != NO_ROSTER_ACCOUNT_NAME):
405
413
  print(f"warning: spent mark names {name}, an account outside the roster; choose waits until its reset", file=sys.stderr)
406
414
 
407
415
 
@@ -947,6 +947,41 @@ def test_should_wait_for_spent_default_home_until_the_mark_passes(
947
947
  assert running_decision["home"] == str(home.resolve())
948
948
 
949
949
 
950
+ def test_should_run_on_a_roster_account_while_a_no_roster_default_mark_is_live(
951
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
952
+ ) -> None:
953
+ account = _account("personal")
954
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"personal": _meters(99, 99)}))
955
+ _freeze_clock(monkeypatch, NOW)
956
+ no_roster_account = Account(Product.CODEX, "default", Path(), False)
957
+ account_broker._save_state(
958
+ account_broker.broker_state_path(),
959
+ {"meters": {}, "spent": {account_broker._state_key(no_roster_account): (NOW + timedelta(hours=1)).timestamp()}, "affinity": {}},
960
+ )
961
+
962
+ code = account_broker.main(("choose", "--product", "codex"))
963
+
964
+ decision = json.loads(capsys.readouterr().out)["decision"]
965
+ assert code == 0
966
+ assert decision["action"] == "run"
967
+ assert decision["account"] == "personal"
968
+
969
+
970
+ def test_should_not_save_a_default_spent_mark_while_a_roster_exists(
971
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
972
+ ) -> None:
973
+ account = _account("personal")
974
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"personal": _meters(99, 99)}))
975
+ _freeze_clock(monkeypatch, NOW)
976
+
977
+ code = account_broker.main(("choose", "--product", "codex", "--spent", "default"))
978
+
979
+ decision = json.loads(capsys.readouterr().out)["decision"]
980
+ assert code == 0
981
+ assert decision["account"] == "personal"
982
+ assert account_broker._load_state(account_broker.broker_state_path())["spent"] == {}
983
+
984
+
950
985
  def test_should_keep_a_spent_mark_without_a_reset_for_one_hour(
951
986
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
952
987
  ) -> None:
@@ -68,9 +68,52 @@ BeforeAll {
68
68
  & pwsh -NoProfile -File $scriptUnderTest -RepoPath $Fixture.MirrorPath -StashDirty -LogPath $Fixture.LogPath | Out-Null
69
69
  return $LASTEXITCODE
70
70
  }
71
+
72
+ function Invoke-MirrorSyncScript {
73
+ param([pscustomobject]$Fixture)
74
+ & pwsh -NoProfile -File $scriptUnderTest -RepoPath $Fixture.MirrorPath -StashDirty -Mirror -LogPath $Fixture.LogPath | Out-Null
75
+ return $LASTEXITCODE
76
+ }
77
+
78
+ function Switch-MirrorToFeatureBranch {
79
+ <#
80
+ Leaves the mirror on a feature branch whose commit edits tracked.txt,
81
+ the way a session that checked out a pull request in the mirror does.
82
+ #>
83
+ param([pscustomobject]$Fixture)
84
+ Invoke-TestGit -RepoPath $Fixture.MirrorPath -Arguments @('switch', '-q', '-c', 'feature') | Out-Null
85
+ Write-TestFile -RepoPath $Fixture.MirrorPath -RelativePath 'tracked.txt' -Content "feature`n"
86
+ Invoke-TestGit -RepoPath $Fixture.MirrorPath -Arguments @('commit', '-q', '-am', 'feature edit') | Out-Null
87
+ return Invoke-TestGit -RepoPath $Fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')
88
+ }
71
89
  }
72
90
 
73
91
  Describe 'Sync-RepoMain' {
92
+ It 'skips a mirror left on another branch when -Mirror is absent' {
93
+ $fixture = New-MirrorBehindRemote -Name 'feature-no-mirror'
94
+ $featureTip = Switch-MirrorToFeatureBranch -Fixture $fixture
95
+
96
+ $exitCode = Invoke-SyncScript -Fixture $fixture
97
+
98
+ $exitCode | Should -Be 3
99
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $featureTip
100
+ }
101
+
102
+ It 'returns a mirror left on another branch to main and fast-forwards it with -Mirror' {
103
+ $fixture = New-MirrorBehindRemote -Name 'feature-mirror'
104
+ $featureTip = Switch-MirrorToFeatureBranch -Fixture $fixture
105
+
106
+ $exitCode = Invoke-MirrorSyncScript -Fixture $fixture
107
+
108
+ $exitCode | Should -Be 0
109
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('branch', '--show-current')) | Should -Be 'main'
110
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $fixture.RemoteTip
111
+ [System.IO.File]::ReadAllText((Join-Path $fixture.MirrorPath 'tracked.txt')) | Should -Be "new`n"
112
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('status', '--porcelain')) | Should -BeNullOrEmpty
113
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'feature')) | Should -Be $featureTip
114
+ (Get-Content -LiteralPath $fixture.LogPath -Raw) | Should -Match "left branch 'feature' at $featureTip"
115
+ }
116
+
74
117
  It 'fast-forwards a clean mirror that is behind the remote' {
75
118
  $fixture = New-MirrorBehindRemote -Name 'clean-behind'
76
119
 
@@ -0,0 +1,226 @@
1
+ """Specifications for Sync-RepoMain returning a mirror left on another branch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ _SCRIPT_PATH = Path(__file__).resolve().parents[1] / "Sync-RepoMain.ps1"
10
+ _PWSH = shutil.which("pwsh") or "pwsh"
11
+ _SKIPPED_EXIT_CODE = 3
12
+ _SUCCESS_EXIT_CODE = 0
13
+ _FAILURE_EXIT_CODE = 1
14
+
15
+
16
+ def _git(repository_path: Path, *arguments: str) -> str:
17
+ completed = subprocess.run(
18
+ [
19
+ "git",
20
+ "-C",
21
+ str(repository_path),
22
+ "-c",
23
+ "user.name=tester",
24
+ "-c",
25
+ "user.email=tester@example.com",
26
+ "-c",
27
+ "core.autocrlf=false",
28
+ *arguments,
29
+ ],
30
+ check=True,
31
+ capture_output=True,
32
+ text=True,
33
+ )
34
+ return completed.stdout.strip()
35
+
36
+
37
+ def _mirror_on_feature_branch_behind_remote(
38
+ root_path: Path, should_advance_remote: bool = True
39
+ ) -> tuple[Path, str, str]:
40
+ remote_path = root_path / "remote.git"
41
+ author_path = root_path / "author"
42
+ mirror_path = root_path / "mirror"
43
+ remote_path.mkdir()
44
+ author_path.mkdir()
45
+ _git(remote_path, "init", "-q", "--bare", "-b", "main")
46
+ _git(author_path, "init", "-q", "-b", "main")
47
+ (author_path / "tracked.txt").write_text("old\n", encoding="utf-8")
48
+ _git(author_path, "add", "-A")
49
+ _git(author_path, "commit", "-q", "-m", "first")
50
+ _git(author_path, "remote", "add", "origin", str(remote_path))
51
+ _git(author_path, "push", "-q", "origin", "main")
52
+ subprocess.run(
53
+ [
54
+ "git",
55
+ "-c",
56
+ "core.autocrlf=false",
57
+ "clone",
58
+ "-q",
59
+ str(remote_path),
60
+ str(mirror_path),
61
+ ],
62
+ check=True,
63
+ )
64
+ _git(mirror_path, "config", "core.autocrlf", "false")
65
+ _git(mirror_path, "switch", "-q", "-c", "feature")
66
+ (mirror_path / "tracked.txt").write_text("feature\n", encoding="utf-8")
67
+ _git(mirror_path, "commit", "-q", "-am", "feature edit")
68
+ feature_tip = _git(mirror_path, "rev-parse", "HEAD")
69
+ if should_advance_remote:
70
+ (author_path / "tracked.txt").write_text("new\n", encoding="utf-8")
71
+ _git(author_path, "commit", "-q", "-am", "second")
72
+ _git(author_path, "push", "-q", "origin", "main")
73
+ remote_tip = _git(author_path, "rev-parse", "HEAD")
74
+ return mirror_path, feature_tip, remote_tip
75
+
76
+
77
+ def _run_sync(mirror_path: Path, log_path: Path, *switches: str) -> int:
78
+ completed = subprocess.run(
79
+ [
80
+ _PWSH,
81
+ "-NoProfile",
82
+ "-File",
83
+ str(_SCRIPT_PATH),
84
+ "-RepoPath",
85
+ str(mirror_path),
86
+ *switches,
87
+ "-LogPath",
88
+ str(log_path),
89
+ ],
90
+ capture_output=True,
91
+ text=True,
92
+ check=False,
93
+ )
94
+ return completed.returncode
95
+
96
+
97
+ def test_should_skip_a_feature_branch_checkout_without_mirror(tmp_path: Path) -> None:
98
+ mirror_path, feature_tip, _ = _mirror_on_feature_branch_behind_remote(tmp_path)
99
+ log_path = tmp_path / "sync.log"
100
+
101
+ assert _run_sync(mirror_path, log_path, "-StashDirty") == _SKIPPED_EXIT_CODE
102
+ assert _git(mirror_path, "branch", "--show-current") == "feature"
103
+ assert _git(mirror_path, "rev-parse", "HEAD") == feature_tip
104
+
105
+
106
+ def test_should_return_a_feature_branch_checkout_to_main_with_mirror(
107
+ tmp_path: Path,
108
+ ) -> None:
109
+ mirror_path, feature_tip, remote_tip = _mirror_on_feature_branch_behind_remote(
110
+ tmp_path
111
+ )
112
+ log_path = tmp_path / "sync.log"
113
+
114
+ assert (
115
+ _run_sync(mirror_path, log_path, "-StashDirty", "-Mirror")
116
+ == _SUCCESS_EXIT_CODE
117
+ )
118
+ assert _git(mirror_path, "branch", "--show-current") == "main"
119
+ assert _git(mirror_path, "rev-parse", "HEAD") == remote_tip
120
+ assert (mirror_path / "tracked.txt").read_text(encoding="utf-8") == "new\n"
121
+ assert _git(mirror_path, "status", "--porcelain") == ""
122
+ assert _git(mirror_path, "rev-parse", "feature") == feature_tip
123
+ assert f"left branch 'feature' at {feature_tip}" in log_path.read_text(
124
+ encoding="utf-8"
125
+ )
126
+
127
+
128
+ def test_should_restore_main_files_when_main_is_already_current(
129
+ tmp_path: Path,
130
+ ) -> None:
131
+ mirror_path, feature_tip, remote_tip = _mirror_on_feature_branch_behind_remote(
132
+ tmp_path, should_advance_remote=False
133
+ )
134
+ log_path = tmp_path / "sync.log"
135
+
136
+ assert (
137
+ _run_sync(mirror_path, log_path, "-StashDirty", "-Mirror")
138
+ == _SUCCESS_EXIT_CODE
139
+ )
140
+ assert _git(mirror_path, "rev-parse", "HEAD") == remote_tip
141
+ assert (mirror_path / "tracked.txt").read_text(encoding="utf-8") == "old\n"
142
+ assert _git(mirror_path, "status", "--porcelain") == ""
143
+ assert f"tree of branch 'feature' at {feature_tip}" in _git(
144
+ mirror_path, "stash", "list"
145
+ )
146
+
147
+
148
+ def test_should_return_to_main_without_stash_dirty(tmp_path: Path) -> None:
149
+ mirror_path, _, remote_tip = _mirror_on_feature_branch_behind_remote(tmp_path)
150
+ log_path = tmp_path / "sync.log"
151
+
152
+ assert _run_sync(mirror_path, log_path, "-Mirror") == _SUCCESS_EXIT_CODE
153
+ assert _git(mirror_path, "rev-parse", "HEAD") == remote_tip
154
+ assert _git(mirror_path, "status", "--porcelain") == ""
155
+
156
+
157
+ def test_should_name_a_detached_head_it_leaves(tmp_path: Path) -> None:
158
+ mirror_path, feature_tip, remote_tip = _mirror_on_feature_branch_behind_remote(
159
+ tmp_path
160
+ )
161
+ _git(mirror_path, "switch", "-q", "--detach", feature_tip)
162
+ log_path = tmp_path / "sync.log"
163
+
164
+ assert _run_sync(mirror_path, log_path, "-Mirror") == _SUCCESS_EXIT_CODE
165
+ assert _git(mirror_path, "rev-parse", "HEAD") == remote_tip
166
+ assert f"left detached HEAD at {feature_tip}" in log_path.read_text(
167
+ encoding="utf-8"
168
+ )
169
+
170
+
171
+ def test_should_stash_a_file_only_the_left_checkout_tracks(tmp_path: Path) -> None:
172
+ mirror_path, _, remote_tip = _mirror_on_feature_branch_behind_remote(tmp_path)
173
+ (mirror_path / "feature-only.txt").write_text("feature only\n", encoding="utf-8")
174
+ _git(mirror_path, "add", "feature-only.txt")
175
+ _git(mirror_path, "commit", "-q", "-m", "feature-only file")
176
+ (mirror_path / "local-note.txt").write_text("untracked note\n", encoding="utf-8")
177
+ log_path = tmp_path / "sync.log"
178
+
179
+ assert _run_sync(mirror_path, log_path, "-Mirror") == _SUCCESS_EXIT_CODE
180
+ assert _git(mirror_path, "rev-parse", "HEAD") == remote_tip
181
+ assert not (mirror_path / "feature-only.txt").exists()
182
+ assert (mirror_path / "local-note.txt").exists()
183
+ assert _git(mirror_path, "status", "--porcelain") == "?? local-note.txt"
184
+
185
+
186
+ def test_should_stash_a_left_file_that_main_ignores(tmp_path: Path) -> None:
187
+ mirror_path, _, _ = _mirror_on_feature_branch_behind_remote(tmp_path)
188
+ (mirror_path / "build.out").write_text("feature build\n", encoding="utf-8")
189
+ _git(mirror_path, "add", "build.out")
190
+ _git(mirror_path, "commit", "-q", "-m", "feature build output")
191
+ (mirror_path / ".git" / "info" / "exclude").write_text(
192
+ "build.out\n", encoding="utf-8"
193
+ )
194
+ log_path = tmp_path / "sync.log"
195
+
196
+ assert _run_sync(mirror_path, log_path, "-Mirror") == _SUCCESS_EXIT_CODE
197
+ assert not (mirror_path / "build.out").exists()
198
+ assert "tree of branch 'feature'" in _git(mirror_path, "stash", "list")
199
+
200
+
201
+ def test_should_put_head_back_when_the_return_cannot_reset(tmp_path: Path) -> None:
202
+ mirror_path, feature_tip, _ = _mirror_on_feature_branch_behind_remote(tmp_path)
203
+ lock_path = mirror_path / ".git" / "index.lock"
204
+ lock_path.write_text("", encoding="utf-8")
205
+ log_path = tmp_path / "sync.log"
206
+
207
+ assert _run_sync(mirror_path, log_path, "-Mirror") == _FAILURE_EXIT_CODE
208
+ assert _git(mirror_path, "branch", "--show-current") == "feature"
209
+ assert _git(mirror_path, "rev-parse", "HEAD") == feature_tip
210
+ assert "HEAD and index are back on branch 'feature'" in log_path.read_text(
211
+ encoding="utf-8"
212
+ )
213
+ lock_path.unlink()
214
+ assert _git(mirror_path, "status", "--porcelain") == ""
215
+
216
+
217
+ def test_should_skip_a_checkout_with_staged_changes(tmp_path: Path) -> None:
218
+ mirror_path, feature_tip, _ = _mirror_on_feature_branch_behind_remote(tmp_path)
219
+ (mirror_path / "tracked.txt").write_text("staged\n", encoding="utf-8")
220
+ _git(mirror_path, "add", "tracked.txt")
221
+ log_path = tmp_path / "sync.log"
222
+
223
+ assert _run_sync(mirror_path, log_path, "-Mirror") == _SKIPPED_EXIT_CODE
224
+ assert _git(mirror_path, "branch", "--show-current") == "feature"
225
+ assert _git(mirror_path, "rev-parse", "HEAD") == feature_tip
226
+ assert _git(mirror_path, "diff", "--cached", "--name-only") == "tracked.txt"