forgexa-cli 1.58.1__tar.gz → 1.58.3__tar.gz
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.
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/PKG-INFO +1 -1
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/daemon.py +268 -12
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/pyproject.toml +1 -1
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/README.md +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/setup.cfg +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.58.1 → forgexa_cli-1.58.3}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.58.
|
|
2
|
+
__version__ = "1.58.3"
|
|
@@ -1058,6 +1058,32 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1058
1058
|
def GIT_CLONE_TIMEOUT(self) -> int:
|
|
1059
1059
|
return int(os.environ.get("GIT_CLONE_TIMEOUT", "1800"))
|
|
1060
1060
|
|
|
1061
|
+
@property
|
|
1062
|
+
def GIT_WORKTREE_TIMEOUT(self) -> int:
|
|
1063
|
+
"""Timeout (seconds) for `git worktree add` operations.
|
|
1064
|
+
|
|
1065
|
+
`worktree add` materialises a full checkout of the target branch
|
|
1066
|
+
into the new worktree. On large repos (e.g. HP SI) or busy disks
|
|
1067
|
+
this easily exceeds the 120 s default for ordinary git commands
|
|
1068
|
+
(SI-1181: a fresh-start worktree add timed out after 120 s and
|
|
1069
|
+
failed the node even though the checkout was still progressing).
|
|
1070
|
+
|
|
1071
|
+
Precedence:
|
|
1072
|
+
1. GIT_WORKTREE_TIMEOUT env var (explicit override)
|
|
1073
|
+
2. GIT_CLONE_TIMEOUT env var (backwards compat — an operator
|
|
1074
|
+
who raised the clone timeout for a huge repo wants local
|
|
1075
|
+
checkouts to get comparable headroom)
|
|
1076
|
+
3. 600 s default (10 min — local disk I/O only, no network,
|
|
1077
|
+
so shorter than the clone/fetch/push ceiling)
|
|
1078
|
+
"""
|
|
1079
|
+
env_worktree = os.environ.get("GIT_WORKTREE_TIMEOUT")
|
|
1080
|
+
if env_worktree:
|
|
1081
|
+
return int(env_worktree)
|
|
1082
|
+
env_clone = os.environ.get("GIT_CLONE_TIMEOUT")
|
|
1083
|
+
if env_clone:
|
|
1084
|
+
return int(env_clone)
|
|
1085
|
+
return 600
|
|
1086
|
+
|
|
1061
1087
|
@property
|
|
1062
1088
|
def GIT_FETCH_TIMEOUT(self) -> int:
|
|
1063
1089
|
"""Timeout (seconds) for incremental git fetch operations.
|
|
@@ -1096,6 +1122,26 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1096
1122
|
return int(env_clone)
|
|
1097
1123
|
return 1800
|
|
1098
1124
|
|
|
1125
|
+
@property
|
|
1126
|
+
def GIT_INDEX_LOCK_WAIT(self) -> int:
|
|
1127
|
+
"""Seconds to wait for a concurrently-held git index.lock (SI-1509).
|
|
1128
|
+
|
|
1129
|
+
A live git process (huge add/status on a slow disk) legitimately
|
|
1130
|
+
holds the lock for minutes; failing the node sync after seconds of
|
|
1131
|
+
retries just burns a requeue cycle on the same contention.
|
|
1132
|
+
"""
|
|
1133
|
+
return int(os.environ.get("GIT_INDEX_LOCK_WAIT", "300"))
|
|
1134
|
+
|
|
1135
|
+
@property
|
|
1136
|
+
def GIT_STALE_LOCK_AGE(self) -> int:
|
|
1137
|
+
"""Age (seconds) after which an index.lock is orphaned (SI-1509).
|
|
1138
|
+
|
|
1139
|
+
A lock older than this with the sync still failing is left behind
|
|
1140
|
+
by a SIGKILL'd git process (daemon timeout kills the whole process
|
|
1141
|
+
group) and is removed, as git's own error message recommends.
|
|
1142
|
+
"""
|
|
1143
|
+
return int(os.environ.get("GIT_STALE_LOCK_AGE", "180"))
|
|
1144
|
+
|
|
1099
1145
|
@property
|
|
1100
1146
|
def GIT_CLONE_FILTER(self) -> str:
|
|
1101
1147
|
"""Optional partial-clone filter, e.g. 'blob:none' for blobless clones.
|
|
@@ -1150,7 +1196,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1150
1196
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1151
1197
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1152
1198
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1153
|
-
DAEMON_VERSION = "1.58.
|
|
1199
|
+
DAEMON_VERSION = "1.58.3"
|
|
1154
1200
|
|
|
1155
1201
|
|
|
1156
1202
|
def _detect_client_type() -> str:
|
|
@@ -3419,7 +3465,10 @@ class WorkspaceManager:
|
|
|
3419
3465
|
else:
|
|
3420
3466
|
try:
|
|
3421
3467
|
# Ensure we're on the correct branch
|
|
3422
|
-
await self.
|
|
3468
|
+
await self._git_index_op(
|
|
3469
|
+
"checkout", branch_name, cwd=ws_path,
|
|
3470
|
+
context=f"final sync checkout {branch_name} in {ws_path}",
|
|
3471
|
+
)
|
|
3423
3472
|
except RuntimeError as exc:
|
|
3424
3473
|
sync_error = f"checkout branch failed: {exc}"
|
|
3425
3474
|
else:
|
|
@@ -3427,9 +3476,11 @@ class WorkspaceManager:
|
|
|
3427
3476
|
# branch has diverged (force-pushed by prior phase),
|
|
3428
3477
|
# reset --hard is used below.
|
|
3429
3478
|
try:
|
|
3430
|
-
await self.
|
|
3479
|
+
await self._git_index_op(
|
|
3431
3480
|
"pull", "--ff-only", "origin", branch_name,
|
|
3432
3481
|
cwd=ws_path, project_key=project_key,
|
|
3482
|
+
timeout=settings.GIT_FETCH_TIMEOUT,
|
|
3483
|
+
context=f"final sync pull --ff-only {branch_name} in {ws_path}",
|
|
3433
3484
|
)
|
|
3434
3485
|
sync_success = True
|
|
3435
3486
|
except RuntimeError as exc:
|
|
@@ -3439,9 +3490,10 @@ class WorkspaceManager:
|
|
|
3439
3490
|
# ff-only failed (diverged or remote not yet created) — try
|
|
3440
3491
|
# reset --hard to force-sync to whatever the remote has.
|
|
3441
3492
|
try:
|
|
3442
|
-
await self.
|
|
3493
|
+
await self._git_index_op(
|
|
3443
3494
|
"reset", "--hard", f"origin/{branch_name}",
|
|
3444
3495
|
cwd=ws_path,
|
|
3496
|
+
context=f"final sync reset to origin/{branch_name} in {ws_path}",
|
|
3445
3497
|
)
|
|
3446
3498
|
sync_success = True
|
|
3447
3499
|
except RuntimeError as exc:
|
|
@@ -3826,6 +3878,157 @@ class WorkspaceManager:
|
|
|
3826
3878
|
backup_tag,
|
|
3827
3879
|
)
|
|
3828
3880
|
|
|
3881
|
+
# ── git index.lock contention recovery (SI-1509) ─────────────────────
|
|
3882
|
+
# A git command that writes the index (checkout, reset, pull, add,
|
|
3883
|
+
# commit, status-refresh) refuses to run while the worktree's
|
|
3884
|
+
# index.lock exists. Two very different situations produce that file:
|
|
3885
|
+
# 1. A LIVE concurrent git process (e.g. a huge `git add`/`git status`
|
|
3886
|
+
# on a slow disk) — must be waited out; removing the lock mid-op
|
|
3887
|
+
# corrupts the index.
|
|
3888
|
+
# 2. A STALE lock orphaned by a SIGKILL'd process (the daemon kills
|
|
3889
|
+
# agent process trees on timeout) — never disappears on its own
|
|
3890
|
+
# and must be removed; git's own error message says exactly this.
|
|
3891
|
+
# Before SI-1509 neither case was handled: sync failed after ~6 s of
|
|
3892
|
+
# blind retries with a misleading "fetch timed out or credentials
|
|
3893
|
+
# missing/invalid" message, and the node was requeued to repeat the
|
|
3894
|
+
# same failure (the real checkout error text was dropped).
|
|
3895
|
+
|
|
3896
|
+
_INDEX_LOCK_RE = re.compile(r"'([^']*index\.lock)'")
|
|
3897
|
+
|
|
3898
|
+
@classmethod
|
|
3899
|
+
def _extract_index_lock_path(cls, err_text: str) -> Path | None:
|
|
3900
|
+
"""Parse the index.lock path git reports when it refuses to proceed.
|
|
3901
|
+
|
|
3902
|
+
Git prints the lock path in single quotes in every locale, e.g.
|
|
3903
|
+
fatal: Unable to create '/w/…/_main/.git/worktrees/<id>/index.lock': File exists.
|
|
3904
|
+
fatal: 无法创建 '/w/…/index.lock':文件已存在。
|
|
3905
|
+
"""
|
|
3906
|
+
m = cls._INDEX_LOCK_RE.search(err_text)
|
|
3907
|
+
if not m:
|
|
3908
|
+
return None
|
|
3909
|
+
raw = m.group(1)
|
|
3910
|
+
# On Windows git may print an MSYS-style path (/c/…); leave those
|
|
3911
|
+
# alone — only recover locks whose path we can act on directly.
|
|
3912
|
+
p = Path(raw)
|
|
3913
|
+
return p if p.is_absolute() else None
|
|
3914
|
+
|
|
3915
|
+
@staticmethod
|
|
3916
|
+
def _linked_worktree_index_lock_path(
|
|
3917
|
+
ws_path: Path, main_repo: Path | None,
|
|
3918
|
+
) -> Path | None:
|
|
3919
|
+
"""Return the index.lock path of a structurally-valid linked worktree.
|
|
3920
|
+
|
|
3921
|
+
Unlike `_is_healthy_worktree` this isolates the *lock* condition:
|
|
3922
|
+
if this returns a path, the worktree is fine apart from being
|
|
3923
|
+
locked, so lock recovery alone can restore it without the
|
|
3924
|
+
destructive remove-and-recreate path.
|
|
3925
|
+
"""
|
|
3926
|
+
git_file = ws_path / ".git"
|
|
3927
|
+
if not git_file.exists() or git_file.is_dir():
|
|
3928
|
+
return None
|
|
3929
|
+
try:
|
|
3930
|
+
content = git_file.read_text().strip()
|
|
3931
|
+
except OSError:
|
|
3932
|
+
return None
|
|
3933
|
+
if not content.startswith("gitdir: "):
|
|
3934
|
+
return None
|
|
3935
|
+
gitdir = Path(content.split("gitdir: ", 1)[1].strip())
|
|
3936
|
+
if main_repo is not None:
|
|
3937
|
+
try:
|
|
3938
|
+
gitdir.resolve().relative_to((main_repo / ".git" / "worktrees").resolve())
|
|
3939
|
+
except (ValueError, OSError):
|
|
3940
|
+
return None
|
|
3941
|
+
lock = gitdir / "index.lock"
|
|
3942
|
+
return lock if lock.exists() else None
|
|
3943
|
+
|
|
3944
|
+
async def _wait_or_remove_index_lock(
|
|
3945
|
+
self, lock_path: Path, *, max_wait: float, stale_age: float,
|
|
3946
|
+
context: str, poll_interval: float = 5.0,
|
|
3947
|
+
) -> bool:
|
|
3948
|
+
"""Wait out a live index.lock holder; remove the lock once stale.
|
|
3949
|
+
|
|
3950
|
+
Policy:
|
|
3951
|
+
• Poll every 5 s; the holder finishing removes the lock itself.
|
|
3952
|
+
• If the lock's mtime age exceeds ``stale_age`` seconds, the
|
|
3953
|
+
holder is almost certainly gone (SIGKILL leaves exactly this
|
|
3954
|
+
state) — unlink it as git's error message recommends.
|
|
3955
|
+
• Give up after ``max_wait`` seconds and return False so callers
|
|
3956
|
+
can fall back to their existing failure handling.
|
|
3957
|
+
"""
|
|
3958
|
+
deadline = time.monotonic() + max_wait
|
|
3959
|
+
last_age = -1.0
|
|
3960
|
+
while True:
|
|
3961
|
+
if not lock_path.exists():
|
|
3962
|
+
if last_age >= 0:
|
|
3963
|
+
logger.info(
|
|
3964
|
+
"%s: index.lock %s disappeared — holder finished; retrying",
|
|
3965
|
+
context, lock_path,
|
|
3966
|
+
)
|
|
3967
|
+
return True
|
|
3968
|
+
try:
|
|
3969
|
+
age = time.time() - lock_path.stat().st_mtime
|
|
3970
|
+
except FileNotFoundError:
|
|
3971
|
+
continue # lock vanished between exists() and stat()
|
|
3972
|
+
except OSError:
|
|
3973
|
+
age = 0.0 # unreadable mtime (permissions) — treat as fresh
|
|
3974
|
+
if age > stale_age:
|
|
3975
|
+
try:
|
|
3976
|
+
lock_path.unlink()
|
|
3977
|
+
except FileNotFoundError:
|
|
3978
|
+
return True
|
|
3979
|
+
except OSError as rm_exc:
|
|
3980
|
+
logger.warning(
|
|
3981
|
+
"%s: could not remove stale index.lock %s (%s) — retrying",
|
|
3982
|
+
context, lock_path, rm_exc,
|
|
3983
|
+
)
|
|
3984
|
+
else:
|
|
3985
|
+
logger.warning(
|
|
3986
|
+
"%s: removed stale index.lock %s (age %.0fs > %.0fs; "
|
|
3987
|
+
"left behind by an interrupted git process)",
|
|
3988
|
+
context, lock_path, age, stale_age,
|
|
3989
|
+
)
|
|
3990
|
+
return True
|
|
3991
|
+
if time.monotonic() >= deadline:
|
|
3992
|
+
logger.warning(
|
|
3993
|
+
"%s: index.lock %s still held after %.0fs — giving up",
|
|
3994
|
+
context, lock_path, max_wait,
|
|
3995
|
+
)
|
|
3996
|
+
return False
|
|
3997
|
+
if last_age < 0:
|
|
3998
|
+
logger.info(
|
|
3999
|
+
"%s: waiting up to %.0fs for concurrent git process holding %s",
|
|
4000
|
+
context, max_wait, lock_path,
|
|
4001
|
+
)
|
|
4002
|
+
last_age = age
|
|
4003
|
+
await asyncio.sleep(poll_interval)
|
|
4004
|
+
|
|
4005
|
+
async def _git_index_op(
|
|
4006
|
+
self, *args: str, cwd: Path | str, context: str,
|
|
4007
|
+
project_key: str | None = None, timeout: int = 120,
|
|
4008
|
+
) -> str:
|
|
4009
|
+
"""Run a git command that writes the worktree index, with lock recovery.
|
|
4010
|
+
|
|
4011
|
+
On index.lock contention (live or stale) this waits out or clears
|
|
4012
|
+
the lock and retries once instead of failing the whole node sync —
|
|
4013
|
+
see the SI-1509 note above the helpers.
|
|
4014
|
+
"""
|
|
4015
|
+
try:
|
|
4016
|
+
return await self._git(*args, cwd=cwd, timeout=timeout, project_key=project_key)
|
|
4017
|
+
except RuntimeError as exc:
|
|
4018
|
+
lock_path = self._extract_index_lock_path(str(exc))
|
|
4019
|
+
if lock_path is None:
|
|
4020
|
+
raise
|
|
4021
|
+
recovered = await self._wait_or_remove_index_lock(
|
|
4022
|
+
lock_path,
|
|
4023
|
+
max_wait=settings.GIT_INDEX_LOCK_WAIT,
|
|
4024
|
+
stale_age=settings.GIT_STALE_LOCK_AGE,
|
|
4025
|
+
context=context,
|
|
4026
|
+
)
|
|
4027
|
+
if not recovered:
|
|
4028
|
+
raise
|
|
4029
|
+
logger.info("%s: index.lock cleared — retrying git %s", context, args[0])
|
|
4030
|
+
return await self._git(*args, cwd=cwd, timeout=timeout, project_key=project_key)
|
|
4031
|
+
|
|
3829
4032
|
async def _is_healthy_worktree(
|
|
3830
4033
|
self,
|
|
3831
4034
|
ws_path: Path,
|
|
@@ -4426,11 +4629,35 @@ class WorkspaceManager:
|
|
|
4426
4629
|
# valid main repo and has no stale Git index lock. Worktrees break
|
|
4427
4630
|
# when directories are moved or an interrupted Git process leaves
|
|
4428
4631
|
# its index locked.
|
|
4429
|
-
|
|
4632
|
+
_worktree_healthy = await self._is_healthy_worktree(
|
|
4430
4633
|
ws_path,
|
|
4431
4634
|
main_repo=main_repo,
|
|
4432
4635
|
require_unlocked=True,
|
|
4433
|
-
)
|
|
4636
|
+
)
|
|
4637
|
+
if not _worktree_healthy:
|
|
4638
|
+
# SI-1509: when the only problem is an index.lock (live
|
|
4639
|
+
# concurrent git process, or a stale one left by a SIGKILL'd
|
|
4640
|
+
# process), recovering just the lock keeps the worktree and
|
|
4641
|
+
# any uncommitted state intact — no need to nuke and re-clone.
|
|
4642
|
+
_lock_path = self._linked_worktree_index_lock_path(ws_path, main_repo)
|
|
4643
|
+
if _lock_path is not None:
|
|
4644
|
+
logger.warning(
|
|
4645
|
+
"Worktree %s is locked by %s — attempting lock recovery before "
|
|
4646
|
+
"falling back to worktree recreation",
|
|
4647
|
+
ws_path, _lock_path,
|
|
4648
|
+
)
|
|
4649
|
+
if await self._wait_or_remove_index_lock(
|
|
4650
|
+
_lock_path,
|
|
4651
|
+
max_wait=settings.GIT_INDEX_LOCK_WAIT,
|
|
4652
|
+
stale_age=settings.GIT_STALE_LOCK_AGE,
|
|
4653
|
+
context=f"worktree health check for {ws_path}",
|
|
4654
|
+
):
|
|
4655
|
+
_worktree_healthy = await self._is_healthy_worktree(
|
|
4656
|
+
ws_path,
|
|
4657
|
+
main_repo=main_repo,
|
|
4658
|
+
require_unlocked=True,
|
|
4659
|
+
)
|
|
4660
|
+
if not _worktree_healthy:
|
|
4434
4661
|
logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
|
|
4435
4662
|
await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
|
|
4436
4663
|
else:
|
|
@@ -4660,15 +4887,25 @@ class WorkspaceManager:
|
|
|
4660
4887
|
sync_success = False
|
|
4661
4888
|
for _sync_attempt in range(3):
|
|
4662
4889
|
try:
|
|
4663
|
-
await self.
|
|
4890
|
+
await self._git_index_op(
|
|
4891
|
+
"checkout", branch_name, cwd=ws_path,
|
|
4892
|
+
context=f"sync checkout {branch_name} in {ws_path}",
|
|
4893
|
+
)
|
|
4664
4894
|
except RuntimeError:
|
|
4665
4895
|
# Branch might not exist locally yet — create tracking branch
|
|
4666
4896
|
try:
|
|
4667
|
-
await self.
|
|
4897
|
+
await self._git_index_op(
|
|
4668
4898
|
"checkout", "-B", branch_name, f"origin/{branch_name}",
|
|
4669
4899
|
cwd=ws_path,
|
|
4900
|
+
context=f"sync checkout -B {branch_name} in {ws_path}",
|
|
4670
4901
|
)
|
|
4671
4902
|
except RuntimeError as exc:
|
|
4903
|
+
# SI-1509: record the REAL checkout error (lock
|
|
4904
|
+
# contention, bad ref, …) — without this the
|
|
4905
|
+
# raised message fell back to a misleading
|
|
4906
|
+
# "fetch timed out or credentials missing/invalid"
|
|
4907
|
+
# even though every fetch had succeeded.
|
|
4908
|
+
_last_sync_err = str(exc)[:300]
|
|
4672
4909
|
if _sync_attempt < 2:
|
|
4673
4910
|
logger.info(
|
|
4674
4911
|
"Branch %s not available yet (attempt %d/3), re-fetching...",
|
|
@@ -4693,13 +4930,15 @@ class WorkspaceManager:
|
|
|
4693
4930
|
logger.warning("Failed to checkout %s after retries: %s", branch_name, exc)
|
|
4694
4931
|
# Reset working tree to match remote branch (fast-forward)
|
|
4695
4932
|
try:
|
|
4696
|
-
await self.
|
|
4933
|
+
await self._git_index_op(
|
|
4697
4934
|
"reset", "--hard", f"origin/{branch_name}",
|
|
4698
4935
|
cwd=ws_path,
|
|
4936
|
+
context=f"sync reset to origin/{branch_name} in {ws_path}",
|
|
4699
4937
|
)
|
|
4700
4938
|
sync_success = True
|
|
4701
4939
|
break
|
|
4702
4940
|
except RuntimeError as exc:
|
|
4941
|
+
_last_sync_err = str(exc)[:300]
|
|
4703
4942
|
if _sync_attempt < 2:
|
|
4704
4943
|
logger.info(
|
|
4705
4944
|
"Could not reset to origin/%s (attempt %d/3), re-fetching...",
|
|
@@ -4761,7 +5000,9 @@ class WorkspaceManager:
|
|
|
4761
5000
|
logger.warning("Could not remove stale worktree %s: %s", ws_path, _rm_exc)
|
|
4762
5001
|
_err_detail = (
|
|
4763
5002
|
f"Git error: {_last_sync_err}" if _last_sync_err
|
|
4764
|
-
else "
|
|
5003
|
+
else "checkout/reset failed repeatedly with no captured "
|
|
5004
|
+
"git error (possible stale index.lock or interrupted "
|
|
5005
|
+
"git process on the daemon host)"
|
|
4765
5006
|
)
|
|
4766
5007
|
raise RuntimeError(
|
|
4767
5008
|
f"Failed to sync branch '{branch_name}' from remote after 3 attempts "
|
|
@@ -5221,7 +5462,8 @@ class WorkspaceManager:
|
|
|
5221
5462
|
) from recovery_exc
|
|
5222
5463
|
self._safe_rmtree(ws_path)
|
|
5223
5464
|
raise RuntimeError(
|
|
5224
|
-
f"Failed to create clean fresh worktree for branch '{branch_name}'
|
|
5465
|
+
f"Failed to create clean fresh worktree for branch '{branch_name}': "
|
|
5466
|
+
f"{self._summarize_git_error(exc)}. "
|
|
5225
5467
|
"Refusing to reuse a stale local branch."
|
|
5226
5468
|
) from exc
|
|
5227
5469
|
else:
|
|
@@ -5257,13 +5499,18 @@ class WorkspaceManager:
|
|
|
5257
5499
|
"""Optional cleanup after task. For now, keep workspaces for debugging."""
|
|
5258
5500
|
pass
|
|
5259
5501
|
|
|
5260
|
-
async def _git(self, *args: str, cwd: Path | str | None = None, timeout: int =
|
|
5502
|
+
async def _git(self, *args: str, cwd: Path | str | None = None, timeout: int | None = None,
|
|
5261
5503
|
project_key: str | None = None) -> str:
|
|
5262
5504
|
"""Run a git command safely using exec (no shell injection).
|
|
5263
5505
|
|
|
5264
5506
|
If a project SSH key is registered, remote-touching commands
|
|
5265
5507
|
(clone, fetch, pull, push) will use GIT_SSH_COMMAND.
|
|
5266
5508
|
|
|
5509
|
+
Timeout resolution (SI-1181): when ``timeout`` is None the default
|
|
5510
|
+
is 120 s for ordinary git commands, but `worktree add` — which
|
|
5511
|
+
materialises a full checkout of the branch — gets the dedicated
|
|
5512
|
+
GIT_WORKTREE_TIMEOUT (default 600 s) instead.
|
|
5513
|
+
|
|
5267
5514
|
Process-group isolation: git is started in its own session so that
|
|
5268
5515
|
SIGKILL on timeout propagates to all of git's children (especially
|
|
5269
5516
|
the ssh subprocess that git forks for remote operations). Without
|
|
@@ -5276,6 +5523,15 @@ class WorkspaceManager:
|
|
|
5276
5523
|
accepts but never sends the git protocol banner) is detected within
|
|
5277
5524
|
~90 s rather than waiting forever.
|
|
5278
5525
|
"""
|
|
5526
|
+
if timeout is None:
|
|
5527
|
+
# `git worktree add` is a full checkout of the target branch;
|
|
5528
|
+
# on large repos (e.g. HP SI) or busy disks it legitimately
|
|
5529
|
+
# exceeds the 120 s default for ordinary git commands.
|
|
5530
|
+
timeout = (
|
|
5531
|
+
settings.GIT_WORKTREE_TIMEOUT
|
|
5532
|
+
if args[:2] == ("worktree", "add")
|
|
5533
|
+
else 120
|
|
5534
|
+
)
|
|
5279
5535
|
env = None
|
|
5280
5536
|
git_prefix_args: list[str] = []
|
|
5281
5537
|
# Inject SSH key for commands that touch the remote
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|