multi-claude-cli 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,709 @@
1
+ """默认目录迁移 `migrate-default`(方案 §5.1.6,事务机制沿用 multi-codex §5.1.5)。
2
+
3
+ 把 S(固定为 ~/.claude)变成账号目录 T(<root>/<名称>),并在 S 位置留兼容软链。
4
+ 迁移后的账号登记为 default 身份:启动命令不设 CLAUDE_CONFIG_DIR,Claude 仍按默认规则使用
5
+ ~/.claude(即软链)、~/.claude.json 和不带后缀的钥匙串条目,所以登录不会失效。
6
+ ~/.claude.json、钥匙串与 .credentials.json 一律不碰。
7
+ 这是本工具唯一可能丢数据的操作,所以按“事务记录 + 按实际状态续跑”的方式实现:
8
+
9
+ - 事务记录 migrate-journal.json 只提供路径、模式和参数;
10
+ - 真实进度一律从文件系统推断(observe + classify),阶段字段只在状态有歧义时区分;
11
+ - 进程在“动作已完成、记录还没更新”之间被杀,重跑也能判断正确。
12
+
13
+ 记号:S 源位置,T 目标目录,B copy 模式下的备份目录(与 S 同目录,改名是原子操作)。
14
+ 核心不变量:只要 B 存在,原始数据就在 B 里,任何分支都不得删除 T 或丢掉事务记录。
15
+ """
16
+
17
+ import errno
18
+ import json
19
+ import os
20
+ import sys
21
+ import time
22
+ from typing import List, Optional
23
+
24
+ from . import accounts, launcher, platform
25
+ from .actions import error, info, warn
26
+ from .config import IDENTITY_DEFAULT, PROXY_INHERIT, Account, Config, load_config, normalize_proxy
27
+ from .fsutil import (KIND_DIR, KIND_LINK, KIND_MISSING, atomic_write, build_manifest, copy_tree,
28
+ diff_manifests, entry_kind, expand, is_under, remove_path, same_target)
29
+
30
+ JOURNAL_VERSION = 1
31
+
32
+ PHASE_PLANNED = "planned"
33
+ PHASE_COPYING = "copying"
34
+ PHASE_MOVED = "moved"
35
+ PHASE_PARKED = "parked"
36
+ PHASE_LINKED = "linked"
37
+ PHASE_REGISTERED = "registered"
38
+ PHASE_ROLLED_BACK = "rolled-back"
39
+
40
+ MODE_RENAME = "rename"
41
+ MODE_COPY = "copy"
42
+
43
+
44
+ class InjectedFailure(Exception):
45
+ """测试钩子抛出的可捕获异常,用来触发回滚路径。"""
46
+
47
+
48
+ def _test_hook(point: str) -> None:
49
+ """测试专用注入点;只有同时设置 MULTI_CLAUDE_TEST_MODE=1 时才生效,正常使用时什么也不做。
50
+
51
+ MULTI_CLAUDE_TEST_FAIL_AT:在该点抛出可捕获异常,走回滚。
52
+ MULTI_CLAUDE_TEST_CRASH_AT:在该点直接 os._exit(137),模拟进程被杀,不做任何清理。
53
+ """
54
+ if platform.test_hook_value("MULTI_CLAUDE_TEST_FAIL_AT") == point:
55
+ raise InjectedFailure(point)
56
+ if platform.test_hook_value("MULTI_CLAUDE_TEST_CRASH_AT") == point:
57
+ sys.stdout.flush()
58
+ sys.stderr.flush()
59
+ os._exit(137)
60
+
61
+
62
+ def journal_path() -> str:
63
+ return os.path.join(platform.state_dir(), "migrate-journal.json")
64
+
65
+
66
+ class JournalError(Exception):
67
+ """事务记录存在但无法读取或内容不完整;此时不能猜测迁移进度,只能交给用户处理。"""
68
+
69
+
70
+ _JOURNAL_KEYS = ("name", "source", "target", "backup", "mode", "phase")
71
+
72
+
73
+ def load_journal() -> Optional[dict]:
74
+ path = journal_path()
75
+ try:
76
+ with open(path, "r", encoding="utf-8") as handle:
77
+ journal = json.load(handle)
78
+ except FileNotFoundError:
79
+ return None
80
+ except (OSError, ValueError) as exc:
81
+ raise JournalError("cannot read the migration journal {}: {}".format(path, exc))
82
+ if not isinstance(journal, dict) or any(not isinstance(journal.get(key), str) for key in _JOURNAL_KEYS):
83
+ raise JournalError("the migration journal {} is incomplete".format(path))
84
+ return journal
85
+
86
+
87
+ def _save_journal(journal: dict) -> None:
88
+ atomic_write(journal_path(), json.dumps(journal, indent=2, ensure_ascii=False) + "\n", mode=0o600)
89
+
90
+
91
+ def _set_phase(journal: dict, phase: str) -> None:
92
+ journal["phase"] = phase
93
+ _save_journal(journal)
94
+
95
+
96
+ def _delete_journal() -> None:
97
+ try:
98
+ os.unlink(journal_path())
99
+ except FileNotFoundError:
100
+ pass
101
+
102
+
103
+ # 当前正在执行的步骤名,只用于错误输出里的 phase=,取值与方案 §9 一致。
104
+ _current_step = {"name": "precheck"}
105
+
106
+
107
+ def _enter_step(name: str) -> None:
108
+ _current_step["name"] = name
109
+
110
+
111
+ class MigrationStop(Exception):
112
+ """迁移按设计停下(冲突、占用、S 被重建等),带退出码,不触发回滚。"""
113
+
114
+ def __init__(self, code: int, message: str = "") -> None:
115
+ super().__init__(message)
116
+ self.code = code
117
+
118
+
119
+ def migrate_default(name: str, source: Optional[str], force_copy: bool, keep_backup: bool,
120
+ proxy: Optional[str], skip_process_check: bool, dry_run: bool) -> int:
121
+ """迁移入口。调用方已持有写锁。
122
+
123
+ source 只供内部续跑使用(回滚完成后按记录里的源重新开始);命令行没有 --source,
124
+ 因为 default 身份依赖“Claude 不设变量时用 $HOME/.claude”,迁移其它目录没有意义。
125
+ 有事务记录时一律按记录续跑,参数以记录为准;名称与记录不同返回 2。
126
+ """
127
+ _enter_step("precheck")
128
+ journal = load_journal()
129
+ source_path = journal["source"] if journal is not None else expand(source or platform.default_source())
130
+ code = _check_environment(source_path)
131
+ if code:
132
+ return code
133
+ if journal is not None:
134
+ return _resume(journal, name, source, force_copy, keep_backup, proxy, skip_process_check, dry_run)
135
+
136
+ config, config_exists = load_config()
137
+ target_path = os.path.join(expand(config.root), name)
138
+ normalized_proxy = normalize_proxy(proxy) if proxy is not None else None
139
+
140
+ try:
141
+ done = _precheck_without_journal(config, config_exists, name, source_path, target_path,
142
+ normalized_proxy, dry_run)
143
+ except MigrationStop as stop:
144
+ if stop.args[0]:
145
+ error(stop.args[0], phase="precheck", path=source_path)
146
+ return stop.code
147
+ if done is not None:
148
+ return done
149
+
150
+ if not skip_process_check:
151
+ code = _busy_check(source_path)
152
+ if code:
153
+ return code
154
+
155
+ root = expand(config.root)
156
+ mode = MODE_COPY if force_copy or not _same_device(source_path, root) else MODE_RENAME
157
+ backup = "{}.multi-claude-bak.{}".format(source_path, time.strftime("%Y%m%d_%H%M%S"))
158
+ if dry_run:
159
+ info("(dry-run) would move {} to {} using {} mode and leave a link at {}".format(
160
+ source_path, target_path, mode, source_path))
161
+ return accounts.EXIT_OK
162
+
163
+ if not os.path.isdir(root):
164
+ os.makedirs(root, mode=0o700)
165
+ journal = {
166
+ "version": JOURNAL_VERSION,
167
+ "name": name,
168
+ "source": source_path,
169
+ "target": target_path,
170
+ "backup": backup,
171
+ "mode": mode,
172
+ "phase": PHASE_PLANNED,
173
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
174
+ "copy": force_copy,
175
+ "keep_backup": keep_backup,
176
+ "proxy": normalized_proxy,
177
+ "skip_process_check": skip_process_check,
178
+ }
179
+ _save_journal(journal)
180
+ _test_hook("journal-planned")
181
+ info("migrating {} to {} ({} mode)".format(source_path, target_path, mode))
182
+ return _run(journal)
183
+
184
+
185
+ def _check_environment(source_path: str) -> int:
186
+ """检查本工具自身的环境(方案 §5.1.6 预检增量);返回 0 表示可以继续。
187
+
188
+ 占用检查第 3 条排除了本工具自身,看不到 `CLAUDE_CONFIG_DIR=~/.claude multi-claude …`
189
+ 这种只对单条命令生效的写法,所以在这里单独拦:此时迁移会和一个指向 S 的环境并存,
190
+ 当前 shell 里启动的 Claude 会在迁移窗口内写 S。
191
+ """
192
+ config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
193
+ if config_dir:
194
+ if os.path.realpath(config_dir) == os.path.realpath(source_path):
195
+ error("CLAUDE_CONFIG_DIR in this shell points to {}; run `unset CLAUDE_CONFIG_DIR` "
196
+ "and rerun".format(source_path), phase="precheck", path=source_path)
197
+ print(" pid={} command=multi-claude usage=config-dir path={}".format(os.getpid(), config_dir),
198
+ file=sys.stderr)
199
+ return accounts.EXIT_BUSY
200
+ warn("CLAUDE_CONFIG_DIR is set to {}; running `claude` directly in this shell uses that "
201
+ "directory, not {}".format(config_dir, source_path))
202
+ for variable in ("CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL"):
203
+ if variable in os.environ:
204
+ warn("{} is set in this shell; it changes where `claude` looks for the login".format(variable))
205
+ warn_isolation_env()
206
+ return accounts.EXIT_OK
207
+
208
+
209
+ def isolation_env_variables() -> List[str]:
210
+ """会破坏账号隔离的环境变量全集:ISOLATION_BREAKING_ENV 加上启动命令会清除的鉴权变量。
211
+
212
+ doctor 的 auth-env 检查与本模块的告警共用这一份清单,避免两处口径不一致。
213
+ """
214
+ variables = list(platform.ISOLATION_BREAKING_ENV)
215
+ variables.extend(name for name in launcher.AUTH_OVERRIDE_ENV if name not in variables)
216
+ return variables
217
+
218
+
219
+ def warn_isolation_env() -> None:
220
+ for variable in isolation_env_variables():
221
+ if not os.environ.get(variable):
222
+ continue
223
+ if variable in launcher.AUTH_OVERRIDE_ENV:
224
+ # 启动命令会清除它,影响的只是直接运行的 `claude`。
225
+ warn("{} is set in the environment; launchers clear it, but running `claude` "
226
+ "directly uses it".format(variable))
227
+ else:
228
+ warn("{} is set in the environment; every account launched from this shell "
229
+ "will share it".format(variable))
230
+
231
+
232
+ def _precheck_without_journal(config: Config, config_exists: bool, name: str, source: str,
233
+ target: str, proxy: Optional[str], dry_run: bool) -> Optional[int]:
234
+ """没有事务记录时的预检(§5.1.5 第 1 步)。返回退出码表示已结束,返回 None 表示继续迁移。"""
235
+ registered = config.find(name)
236
+ source_kind = entry_kind(source)
237
+
238
+ if source_kind == KIND_LINK and entry_kind(target) != KIND_MISSING and same_target(source, target):
239
+ if registered is not None and registered.name == name:
240
+ info("already migrated: {} -> {}".format(source, target))
241
+ return accounts.EXIT_OK
242
+ if registered is None:
243
+ info("{} already links to {}; registering the account".format(source, target))
244
+ return _register(config, config_exists, name, proxy, dry_run)
245
+
246
+ if registered is not None:
247
+ raise MigrationStop(accounts.EXIT_CONFLICT,
248
+ "account {!r} is already registered".format(registered.name))
249
+ if source_kind == KIND_LINK:
250
+ raise MigrationStop(accounts.EXIT_CONFLICT, "source is a link to another location")
251
+ if source_kind == KIND_MISSING:
252
+ raise MigrationStop(accounts.EXIT_CONFLICT, "source does not exist")
253
+ if source_kind != KIND_DIR:
254
+ raise MigrationStop(accounts.EXIT_CONFLICT, "source is not a directory")
255
+ if entry_kind(target) != KIND_MISSING:
256
+ raise MigrationStop(accounts.EXIT_CONFLICT, "target {} already exists".format(target))
257
+ if is_under(target, os.path.realpath(source)) or is_under(os.path.realpath(source), target):
258
+ raise MigrationStop(accounts.EXIT_CONFLICT, "source and target must not contain each other")
259
+
260
+ _check_registration_conflicts(config, config_exists, name, target, proxy)
261
+ return None
262
+
263
+
264
+ def _check_registration_conflicts(config: Config, config_exists: bool, name: str, target: str,
265
+ proxy: Optional[str]) -> None:
266
+ """预检登记阶段会不会冲突(R3):必须在移动数据之前发现,移动后就无法干净退出了。"""
267
+ _check_default_identity(config, name)
268
+ new = _config_with_account(config, name, proxy)
269
+ actions = accounts.plan(config, new, config_exists=config_exists, assume_dirs=[target])
270
+ conflicts = [action for action in actions if action.status == accounts.CONFLICT]
271
+ if conflicts:
272
+ for action in conflicts:
273
+ error(action.reason, phase="precheck", path=action.path)
274
+ raise MigrationStop(accounts.EXIT_CONFLICT, "registration would conflict; nothing was moved")
275
+
276
+
277
+ def _check_default_identity(config: Config, name: str) -> None:
278
+ """一台机器只有一个 ~/.claude,已有别的 default 身份账号时不能再迁移出第二个。"""
279
+ existing = config.default_account()
280
+ if existing is not None and existing.name.casefold() != name.casefold():
281
+ raise MigrationStop(accounts.EXIT_CONFLICT,
282
+ "another account already has the default identity ({!r})".format(existing.name))
283
+
284
+
285
+ def _config_with_account(config: Config, name: str, proxy: Optional[str]) -> Config:
286
+ new = config.copy()
287
+ existing = new.find(name)
288
+ if existing is None:
289
+ new.accounts[name] = Account(name, proxy or PROXY_INHERIT, False, identity=IDENTITY_DEFAULT)
290
+ elif proxy is not None:
291
+ existing.proxy = proxy
292
+ return new
293
+
294
+
295
+ def _register(config: Config, config_exists: bool, name: str, proxy: Optional[str], dry_run: bool) -> int:
296
+ _check_default_identity(config, name)
297
+ new = _config_with_account(config, name, proxy)
298
+ return accounts.converge(config, new, config_exists=config_exists, dry_run=dry_run)
299
+
300
+
301
+ def _busy_check(source: str) -> int:
302
+ """占用检查第 0–5 条(方案 §5.1.7):迁移期间不能有任何 Claude 进程。
303
+
304
+ 任何账号的 Claude 进程都会写 $HOME/.claude(依据 12):rename 窗口内写入会重建真实的 S,
305
+ copy 模式下复制完成到 park 之间写入的内容会随备份一起被删掉。返回 0 可以继续,
306
+ 1 检查本身失败,4 有占用。
307
+ """
308
+ if platform.CHILD_SESSION_ENV in os.environ:
309
+ error("multi-claude is running inside a Claude Code session; run migrate-default from a plain "
310
+ "terminal with no Claude session", phase="busy-check", path=source)
311
+ print(" pid={} command=multi-claude usage=inside-claude-session path={}".format(os.getpid(), source),
312
+ file=sys.stderr)
313
+ print(" note: {} can also be inherited by screen, tmux or programs started by Claude; if you are "
314
+ "sure this is not a Claude session, rerun as `env -u {} multi-claude ...`".format(
315
+ platform.CHILD_SESSION_ENV, platform.CHILD_SESSION_ENV), file=sys.stderr)
316
+ return accounts.EXIT_BUSY
317
+ try:
318
+ busy = platform.find_busy_processes(source)
319
+ users = platform.find_claude_users(source)
320
+ except platform.BusyCheckError as exc:
321
+ error("cannot check whether {} is in use ({}); close every Claude session and rerun with "
322
+ "--skip-process-check to proceed anyway".format(source, exc), phase="busy-check", path=source)
323
+ return accounts.EXIT_ERROR
324
+ for message in users.warnings:
325
+ warn(message)
326
+ if users.unreadable:
327
+ # Linux 上通常是已退出或不可 dump 的进程;macOS 上还包括系统隐去环境的 Apple 自带程序。
328
+ info("the environment of {} process(es) could not be read, so they were not checked for "
329
+ "CLAUDE_CONFIG_DIR (for example, macOS hides it for Apple's own programs such as the "
330
+ "system shells)".format(users.unreadable))
331
+ seen = {process.pid for process in busy}
332
+ busy.extend(process for process in users.busy if process.pid == 0 or process.pid not in seen)
333
+ if not busy:
334
+ return accounts.EXIT_OK
335
+ error("{} is in use; close these processes and rerun:".format(source), phase="busy-check", path=source)
336
+ for process in busy:
337
+ print(" pid={} command={} usage={} path={}".format(process.pid or "-", process.command, process.usage,
338
+ process.path),
339
+ file=sys.stderr)
340
+ parent = os.getppid()
341
+ parent_config = [process.path for process in busy if process.pid == parent and process.usage == "config-dir"]
342
+ if parent_config:
343
+ print(" note: the shell running this command exports CLAUDE_CONFIG_DIR={}; run "
344
+ "`unset CLAUDE_CONFIG_DIR` first".format(parent_config[0]), file=sys.stderr)
345
+ elif any(process.pid == parent for process in busy):
346
+ print(" note: the shell running this command has its working directory inside the source; "
347
+ "cd out of it first", file=sys.stderr)
348
+ if users.stop_commands:
349
+ print(" every Claude account writes to {} (bridge-spawn, state/, ide/), so close all Claude sessions; "
350
+ "to stop background sessions run:".format(source), file=sys.stderr)
351
+ for command in users.stop_commands:
352
+ print(" " + command, file=sys.stderr)
353
+ if any(process.usage == "daemon-service" for process in busy):
354
+ print(" the background service is installed and launchd/systemd may restart it at any time; run "
355
+ "`claude daemon uninstall` first and install it again after the migration", file=sys.stderr)
356
+ return accounts.EXIT_BUSY
357
+
358
+
359
+ def _same_device(source: str, root: str) -> bool:
360
+ probe = root if os.path.isdir(root) else os.path.dirname(root)
361
+ while not os.path.isdir(probe):
362
+ probe = os.path.dirname(probe)
363
+ return os.stat(source).st_dev == os.stat(probe).st_dev
364
+
365
+
366
+ def _resume(journal: dict, name: str, source: Optional[str], force_copy: bool, keep_backup: bool,
367
+ proxy: Optional[str], skip_process_check: bool, dry_run: bool) -> int:
368
+ """按事务记录续跑一次未完成的迁移。
369
+
370
+ 参数一律以记录为准:续跑时用户可能忘了带上首次的 --keep-backup 等选项,
371
+ 按命令行走会删掉本想保留的备份。名称(或内部续跑传入的源)不同说明用户想做另一次迁移,
372
+ 返回 2 并提示先完成记录中的那次;其它选项不同只给出警告。
373
+ """
374
+ _enter_step("resume")
375
+ if name.casefold() != journal["name"].casefold() or (
376
+ source is not None and expand(source) != journal["source"]):
377
+ error("an unfinished migration of {!r} from {} exists; rerun `multi-claude migrate-default {}` "
378
+ "to finish it first".format(journal["name"], journal["source"], journal["name"]),
379
+ phase="resume", path=journal_path())
380
+ return accounts.EXIT_USAGE
381
+ if keep_backup != journal.get("keep_backup") or (proxy is not None and
382
+ normalize_proxy(proxy) != journal.get("proxy")):
383
+ warn("options differ from the unfinished migration; using the recorded options "
384
+ "(keep_backup={}, proxy={})".format(journal.get("keep_backup"), journal.get("proxy")))
385
+ if skip_process_check != bool(journal.get("skip_process_check")):
386
+ warn("--skip-process-check differs from the unfinished migration; using the recorded value ({})".format(
387
+ bool(journal.get("skip_process_check"))))
388
+ if force_copy and journal.get("mode") != MODE_COPY:
389
+ warn("--copy is ignored while resuming; the recorded mode is {}".format(journal.get("mode")))
390
+ info("resuming migration of {} (recorded phase: {})".format(journal["source"], journal["phase"]))
391
+ if dry_run:
392
+ state = _observe(journal)
393
+ info("(dry-run) current state: source={} target={} backup={}".format(*state))
394
+ return accounts.EXIT_OK
395
+ return _run(journal)
396
+
397
+
398
+ def _observe(journal: dict):
399
+ """返回 (S 的状态, T 是否存在, B 是否存在)。S 的状态:link-target、link-other、dir、missing、other。"""
400
+ source, target, backup = journal["source"], journal["target"], journal["backup"]
401
+ kind = entry_kind(source)
402
+ if kind == KIND_LINK:
403
+ source_state = "link-target" if same_target(source, target) and os.path.isdir(target) else "link-other"
404
+ elif kind in (KIND_DIR, KIND_MISSING):
405
+ source_state = kind
406
+ else:
407
+ source_state = "other"
408
+ return source_state, entry_kind(target) != KIND_MISSING, entry_kind(backup) != KIND_MISSING
409
+
410
+
411
+ def _run(journal: dict) -> int:
412
+ """按实际状态逐步推进,直到完成或按设计停下;可捕获的异常走回滚。"""
413
+ try:
414
+ return _advance(journal)
415
+ except MigrationStop as stop:
416
+ if stop.args[0]:
417
+ error(stop.args[0], phase=_current_step["name"], path=journal["source"])
418
+ _print_state(journal)
419
+ return stop.code
420
+ except Exception as exc: # noqa: BLE001 —— 任何未预期的异常都要尝试把数据还原到完整位置
421
+ error("{}: {}".format(type(exc).__name__, exc), phase=_current_step["name"], path=journal["source"])
422
+ return _rollback(journal)
423
+
424
+
425
+ def _advance(journal: dict) -> int:
426
+ verified_this_run = False
427
+ while True:
428
+ source_state, target_exists, backup_exists = _observe(journal)
429
+ phase = journal["phase"]
430
+ mode = journal["mode"]
431
+
432
+ # 第 1 行:回滚未完成,先把回滚做完,再按“没有事务记录”从头开始。
433
+ if phase == PHASE_ROLLED_BACK:
434
+ _finish_rollback(journal)
435
+ info("previous rollback finished; starting the migration again")
436
+ return migrate_default(journal["name"], journal["source"], bool(journal.get("copy")) or mode == MODE_COPY,
437
+ bool(journal.get("keep_backup")), journal.get("proxy"),
438
+ bool(journal.get("skip_process_check")), False)
439
+
440
+ # 第 2 行:兼容软链已建好,只剩登记与清理。
441
+ if source_state == "link-target":
442
+ return _step_register_and_cleanup(journal)
443
+
444
+ # 第 3 行:S 已改名为 B,还没建软链。T 必须仍在,否则(只会是人工删除)按第 10 行处理,
445
+ # 避免在 S 位置建出一条断链。
446
+ if source_state == KIND_MISSING and backup_exists and target_exists:
447
+ _step_link(journal)
448
+ continue
449
+
450
+ # 第 4 行:已 park,但 S 又被重建。原始数据在 B,只能报错等用户处理。
451
+ if source_state == KIND_DIR and backup_exists:
452
+ raise MigrationStop(accounts.EXIT_ERROR, _rebuilt_message(journal, journal["backup"]))
453
+
454
+ # 第 5 行:rename 已完成,还没建软链。
455
+ if source_state == KIND_MISSING and not backup_exists and target_exists and mode == MODE_RENAME:
456
+ _step_link(journal)
457
+ continue
458
+
459
+ if source_state == KIND_DIR and not backup_exists:
460
+ # 第 6 行:还没移动任何数据,重新预检后开始移动。
461
+ if not target_exists:
462
+ _step_move(journal)
463
+ verified_this_run = mode == MODE_COPY
464
+ continue
465
+ if mode == MODE_COPY and phase in (PHASE_PLANNED, PHASE_COPYING):
466
+ # 第 7 行:复制被中断,T 不完整,删掉重来。
467
+ info("removing incomplete copy {}".format(journal["target"]))
468
+ remove_path(journal["target"])
469
+ _set_phase(journal, PHASE_PLANNED)
470
+ continue
471
+ if mode == MODE_COPY and phase == PHASE_MOVED:
472
+ # 第 8 行:已复制并校验,尚未 park。
473
+ _step_park(journal, verified_this_run)
474
+ verified_this_run = False
475
+ continue
476
+ if mode == MODE_RENAME:
477
+ # 第 9 行:rename 后 S 被重建。
478
+ raise MigrationStop(accounts.EXIT_ERROR, _rebuilt_message(journal, journal["target"]))
479
+
480
+ # 第 10 行:只会在人工改动后出现,不自动处理。
481
+ raise MigrationStop(accounts.EXIT_ERROR, "cannot determine the migration state automatically")
482
+
483
+
484
+ def _step_move(journal: dict) -> None:
485
+ """第 1~3 步:重新预检、占用检查、移动数据。只在 S 是真实目录、T 与 B 都不存在时调用。
486
+
487
+ 预检或占用检查不通过时,数据一点都还没动,直接删除事务记录后停下,
488
+ 这样不会留下一条记录挡住其它命令。
489
+ """
490
+ _enter_step("precheck")
491
+ config, config_exists = load_config()
492
+ _check_registration_conflicts_or_abandon(config, config_exists, journal)
493
+ _enter_step("busy-check")
494
+ if not journal.get("skip_process_check"):
495
+ code = _busy_check(journal["source"])
496
+ if code:
497
+ # 还没移动任何数据,放弃这次迁移,不留事务记录挡住其它命令。
498
+ _delete_journal()
499
+ raise MigrationStop(code)
500
+
501
+ source, target = journal["source"], journal["target"]
502
+ _enter_step("move")
503
+ if journal["mode"] == MODE_RENAME:
504
+ try:
505
+ os.rename(source, target)
506
+ except OSError as exc:
507
+ if exc.errno != errno.EXDEV:
508
+ raise
509
+ # 跨设备无法 rename:先把模式写进记录再复制,中断后续跑才会按 copy 规则处理。
510
+ journal["mode"] = MODE_COPY
511
+ _set_phase(journal, PHASE_PLANNED)
512
+ info("source and target are on different file systems; switching to copy mode")
513
+ return
514
+ _test_hook("rename-target")
515
+ _set_phase(journal, PHASE_MOVED)
516
+ _test_hook("journal-moved")
517
+ return
518
+
519
+ _set_phase(journal, PHASE_COPYING)
520
+ _test_hook("journal-copying")
521
+ copied = [0]
522
+
523
+ def on_file(_path: str) -> None:
524
+ copied[0] += 1
525
+ if copied[0] == 1:
526
+ _test_hook("copy-midway")
527
+
528
+ skipped = copy_tree(source, target, on_file)
529
+ _test_hook("copy-done")
530
+ for rel in skipped:
531
+ info("skipped special file (socket/FIFO/device): {}".format(os.path.join(source, rel)))
532
+ _verify_copy(journal)
533
+ _set_phase(journal, PHASE_MOVED)
534
+ _test_hook("journal-moved")
535
+
536
+
537
+ def _check_registration_conflicts_or_abandon(config: Config, config_exists: bool, journal: dict) -> None:
538
+ try:
539
+ _check_registration_conflicts(config, config_exists, journal["name"], journal["target"],
540
+ journal.get("proxy"))
541
+ except MigrationStop:
542
+ _delete_journal()
543
+ raise
544
+
545
+
546
+ def _verify_copy(journal: dict) -> None:
547
+ """比对 S 与 T 的清单和 SHA-256;不一致时删除 T,S 不动,返回错误。"""
548
+ _enter_step("verify")
549
+ source_manifest, _ = build_manifest(journal["source"])
550
+ target_manifest, _ = build_manifest(journal["target"])
551
+ _test_hook("verify")
552
+ mismatched = diff_manifests(source_manifest, target_manifest)
553
+ if mismatched:
554
+ remove_path(journal["target"])
555
+ _delete_journal()
556
+ shown = ", ".join(mismatched[:5]) + (" ..." if len(mismatched) > 5 else "")
557
+ raise MigrationStop(accounts.EXIT_ERROR,
558
+ "copy verification failed for {} entries ({}); the copy was removed and "
559
+ "the source is untouched".format(len(mismatched), shown))
560
+
561
+
562
+ def _step_park(journal: dict, verified_this_run: bool) -> None:
563
+ """copy 模式:再做一次占用检查,然后把 S 改名为 B(§5.1.5 第 3 步第 4 点)。
564
+
565
+ 复制和校验可能耗时几分钟,这期间写进 S 的数据不会出现在 T 里,所以 park 前必须再查一次。
566
+ 续跑时(本次运行没有校验过)还要重新校验,确认 T 仍与 S 一致。
567
+ """
568
+ _enter_step("park")
569
+ if not journal.get("skip_process_check"):
570
+ code = _busy_check(journal["source"])
571
+ if code:
572
+ raise MigrationStop(code)
573
+ if not verified_this_run:
574
+ source_manifest, _ = build_manifest(journal["source"])
575
+ target_manifest, _ = build_manifest(journal["target"])
576
+ if diff_manifests(source_manifest, target_manifest):
577
+ info("the source changed since it was copied; copying again")
578
+ remove_path(journal["target"])
579
+ _set_phase(journal, PHASE_PLANNED)
580
+ return
581
+ os.rename(journal["source"], journal["backup"])
582
+ _test_hook("rename-backup")
583
+ _set_phase(journal, PHASE_PARKED)
584
+ _test_hook("journal-parked")
585
+
586
+
587
+ def _step_link(journal: dict) -> None:
588
+ """第 4 步:在 S 位置建指向 T 的兼容软链。S 被别的进程重建时报错,不回滚。"""
589
+ _enter_step("link")
590
+ source, target = journal["source"], journal["target"]
591
+ kind = entry_kind(source)
592
+ if kind != KIND_MISSING:
593
+ if kind == KIND_LINK and same_target(source, target):
594
+ return
595
+ where = journal["backup"] if entry_kind(journal["backup"]) != KIND_MISSING else target
596
+ raise MigrationStop(accounts.EXIT_ERROR, _rebuilt_message(journal, where))
597
+ platform.create_link(target, source)
598
+ _test_hook("link")
599
+ _set_phase(journal, PHASE_LINKED)
600
+ _test_hook("journal-linked")
601
+
602
+
603
+ def _step_register_and_cleanup(journal: dict) -> int:
604
+ """第 5 步:登记账号、生成启动命令,然后按记录决定是否删除 B,最后删除事务记录。"""
605
+ _enter_step("register")
606
+ config, config_exists = load_config()
607
+ code = _register(config, config_exists, journal["name"], journal.get("proxy"), False)
608
+ if code != accounts.EXIT_OK:
609
+ raise MigrationStop(code, "data is in place but registration failed; fix the problem above "
610
+ "and rerun the same command")
611
+ _test_hook("register")
612
+ _set_phase(journal, PHASE_REGISTERED)
613
+ _test_hook("journal-registered")
614
+ _enter_step("cleanup")
615
+ backup = journal["backup"]
616
+ if journal["mode"] == MODE_COPY and entry_kind(backup) != KIND_MISSING:
617
+ if journal.get("keep_backup"):
618
+ info("backup kept at {}".format(backup))
619
+ else:
620
+ try:
621
+ remove_path(backup)
622
+ _test_hook("remove-backup")
623
+ except Exception as exc: # noqa: BLE001 —— 迁移已经完成,删备份失败只能提示,不能让流程卡住
624
+ warn("could not remove backup {}: {}: {}".format(backup, type(exc).__name__, exc))
625
+ _delete_journal()
626
+ info("migrated {} -> {}".format(journal["source"], journal["target"]))
627
+ return accounts.EXIT_OK
628
+
629
+
630
+ def _rollback(journal: dict) -> int:
631
+ """回滚:只把数据恢复到仍然完整的位置,只在 S 为空(或 S 未动过)时执行。"""
632
+ _enter_step("rollback")
633
+ source_state, target_exists, backup_exists = _observe(journal)
634
+ try:
635
+ if journal["mode"] == MODE_RENAME and source_state == KIND_MISSING and target_exists \
636
+ and not backup_exists:
637
+ os.rename(journal["target"], journal["source"])
638
+ _delete_journal()
639
+ info("rolled back: {} restored".format(journal["source"]))
640
+ elif journal["mode"] == MODE_COPY and source_state == KIND_DIR and not backup_exists:
641
+ # park 之前失败:S 没动过,T 只是副本。
642
+ remove_path(journal["target"])
643
+ _delete_journal()
644
+ info("rolled back: removed the partial copy; {} is untouched".format(journal["source"]))
645
+ elif journal["mode"] == MODE_COPY and source_state == KIND_MISSING and backup_exists:
646
+ # 先写记录再动文件:回滚在任何一步中断,续跑表第 1 行都能接着做完。
647
+ _set_phase(journal, PHASE_ROLLED_BACK)
648
+ _test_hook("journal-rolled-back")
649
+ _finish_rollback(journal)
650
+ info("rolled back: {} restored from backup".format(journal["source"]))
651
+ else:
652
+ info("no automatic rollback in this state; rerun the same command to continue")
653
+ _print_state(journal)
654
+ except Exception as exc: # noqa: BLE001 —— 回滚失败必须保留事务记录并报告现场
655
+ error("rollback failed: {}: {}".format(type(exc).__name__, exc), phase="rollback",
656
+ path=journal["source"])
657
+ _print_state(journal)
658
+ return accounts.EXIT_ERROR
659
+
660
+
661
+ def _finish_rollback(journal: dict) -> None:
662
+ """完成 copy 模式 park 之后的回滚:B 改回 S,删除 T,最后删除事务记录。
663
+
664
+ 调用前事务记录必须已处于 rolled-back:每一步都可能被中断,续跑表第 1 行会再次调用本函数,
665
+ 所以每一步都要能重复执行(B 已改回就跳过,T 已删就忽略)。
666
+ 状态不是“S 为真实目录且 B 不存在”时不删除 T,抛出 MigrationStop 交给用户处理。
667
+ """
668
+ source, target, backup = journal["source"], journal["target"], journal["backup"]
669
+ if entry_kind(source) == KIND_MISSING and entry_kind(backup) != KIND_MISSING:
670
+ os.rename(backup, source)
671
+ _test_hook("rollback-restore")
672
+ if entry_kind(source) != KIND_DIR or entry_kind(backup) != KIND_MISSING:
673
+ raise MigrationStop(accounts.EXIT_ERROR, "cannot finish the rollback automatically")
674
+ remove_path(target)
675
+ _test_hook("rollback-remove-target")
676
+ _delete_journal()
677
+
678
+
679
+ def _rebuilt_message(journal: dict, data_location: str) -> str:
680
+ return ("{} was recreated by another process while migrating. The complete data is in {}. "
681
+ "Close the process that recreated it, move the recreated {} away, and rerun "
682
+ "`multi-claude migrate-default {}`.").format(
683
+ journal["source"], data_location, journal["source"], journal["name"])
684
+
685
+
686
+ def _print_state(journal: dict) -> None:
687
+ """报告现场:S、T、B 的当前状态,事务记录位置,以及放弃迁移时的手工收尾步骤。"""
688
+ source_state, target_exists, backup_exists = _observe(journal)
689
+ lines = [
690
+ " source {}: {}".format(journal["source"], source_state),
691
+ " target {}: {}".format(journal["target"], "exists" if target_exists else "missing"),
692
+ " backup {}: {}".format(journal["backup"], "exists" if backup_exists else "missing"),
693
+ " journal {} (phase {})".format(journal_path(), journal.get("phase")),
694
+ ]
695
+ if entry_kind(journal_path()) != KIND_MISSING:
696
+ data = journal["backup"] if backup_exists else journal["target"]
697
+ lines.append(" to abandon this migration: make sure the complete data in {} is back at {}, "
698
+ "then delete {}".format(data, journal["source"], journal_path()))
699
+ print("\n".join(lines), file=sys.stderr)
700
+
701
+
702
+ def pending_journal_notice() -> Optional[str]:
703
+ journal = load_journal()
704
+ if journal is None:
705
+ return None
706
+ return "an unfinished migration exists; rerun `multi-claude migrate-default {}` to finish it".format(
707
+ journal.get("name"))
708
+
709
+