chipfoundry-cli 2.3.1__tar.gz → 2.3.12__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: chipfoundry-cli
3
- Version: 2.3.1
3
+ Version: 2.3.12
4
4
  Summary: CLI tool to automate ChipFoundry project submission to SFTP server
5
5
  Home-page: https://chipfoundry.io
6
6
  License: Apache-2.0
@@ -1,2 +1,2 @@
1
1
  """ChipFoundry CLI package: Automate project submission to SFTP."""
2
- __version__ = "0.1.0"
2
+ __version__ = "2.3.2"
@@ -1,5 +1,6 @@
1
1
  import click
2
2
  import getpass
3
+ from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo
3
4
  from chipfoundry_cli.utils import (
4
5
  collect_project_files, ensure_cf_directory, update_or_create_project_json,
5
6
  sftp_connect, upload_with_progress, sftp_ensure_dirs, sftp_download_recursive,
@@ -1653,6 +1654,13 @@ STATUS_HINTS = {
1653
1654
  "CHANGES_REQUESTED": "Changes requested by the review team. See notes above.",
1654
1655
  }
1655
1656
 
1657
+ REMOTE_PRECHECK_STATUS_COLORS = {
1658
+ "queued": "yellow",
1659
+ "running": "cyan bold",
1660
+ "completed": "green",
1661
+ "failed": "red",
1662
+ }
1663
+
1656
1664
 
1657
1665
  def _show_platform_status(project_root: str):
1658
1666
  """Show the platform pipeline panel if the project is linked. Returns True if shown."""
@@ -1695,6 +1703,28 @@ def _show_platform_status(project_root: str):
1695
1703
  lines.append(f"[bold]Tapeout:[/bold] [{tc}]{tl}[/{tc}]")
1696
1704
  if project.get('gds_hash'):
1697
1705
  lines.append(f"[bold]GDS Hash:[/bold] {project['gds_hash'][:16]}...")
1706
+ rj = project.get("latest_remote_precheck_job")
1707
+ if isinstance(rj, dict) and rj.get("status"):
1708
+ jst = str(rj.get("status", ""))
1709
+ jc = REMOTE_PRECHECK_STATUS_COLORS.get(jst, "white")
1710
+ lines.append(f"[bold]Remote precheck:[/bold] [{jc}]{jst}[/{jc}]")
1711
+ ref = rj.get("git_ref")
1712
+ if ref:
1713
+ lines.append(f"[dim] git ref: {ref}[/dim]")
1714
+ created = rj.get("created_at")
1715
+ if created and isinstance(created, str):
1716
+ lines.append(f"[dim] started: {created[:19]}[/dim]")
1717
+ if jst in ("completed", "failed"):
1718
+ done = rj.get("completed_at")
1719
+ if done and isinstance(done, str):
1720
+ lines.append(f"[dim] finished: {done[:19]}[/dim]")
1721
+ if jst == "failed" and rj.get("error_message"):
1722
+ err = str(rj["error_message"])
1723
+ if len(err) > 240:
1724
+ err = err[:237] + "..."
1725
+ lines.append(f"[red] {err}[/red]")
1726
+ if jst == "completed" and rj.get("github_pr_url"):
1727
+ lines.append(f"[green] PR:[/green] {rj['github_pr_url']}")
1698
1728
  if project.get('updated_at'):
1699
1729
  lines.append(f"[bold]Updated:[/bold] {project['updated_at'][:10]}")
1700
1730
  if project.get('admin_review_notes'):
@@ -3248,7 +3278,21 @@ def _upload_precheck_results(project_json_path: Path):
3248
3278
  @click.option('--magic-drc', is_flag=True, help='Include Magic DRC check (optional, off by default)')
3249
3279
  @click.option('--checks', multiple=True, help='Specific checks to run (can be specified multiple times)')
3250
3280
  @click.option('--dry-run', is_flag=True, help='Show the command without running')
3251
- def precheck(project_root, skip_checks, magic_drc, checks, dry_run):
3281
+ @click.option('--remote', is_flag=True, help='Queue precheck on the chipIgnite platform (requires cf login + linked project)')
3282
+ @click.option(
3283
+ '--poll',
3284
+ is_flag=True,
3285
+ help='With --remote: poll until the job finishes and print progress (5s interval).',
3286
+ )
3287
+ @click.option('--git-ref', default='main', show_default=True, help='Git branch or tag for remote precheck')
3288
+ @click.option(
3289
+ '--wait-timeout',
3290
+ type=int,
3291
+ default=7200,
3292
+ show_default=True,
3293
+ help='With --remote --poll: max seconds to wait (0 = no limit). Ignored without --poll.',
3294
+ )
3295
+ def precheck(project_root, skip_checks, magic_drc, checks, dry_run, remote, poll, git_ref, wait_timeout):
3252
3296
  """Run precheck validation on the project.
3253
3297
 
3254
3298
  This runs the cf-precheck tool to validate your design before
@@ -3259,6 +3303,13 @@ def precheck(project_root, skip_checks, magic_drc, checks, dry_run):
3259
3303
  cf precheck --skip-checks lvs # Skip LVS check
3260
3304
  cf precheck --magic-drc # Include optional Magic DRC
3261
3305
  cf precheck --checks topcell_check # Run specific checks only
3306
+ cf precheck --remote # Queue on platform; exit when accepted
3307
+ cf precheck --remote --poll # Wait and stream progress
3308
+ cf precheck --remote --poll --wait-timeout 0 # Poll until done (no time limit)
3309
+
3310
+ Remote precheck requires your local HEAD to match origin for --git-ref, and precheck
3311
+ inputs (wrapper GDS, verilog/rtl/user_defines.v when the GPIO check runs, and tracked
3312
+ .cf/project.json) to match that commit.
3262
3313
  """
3263
3314
  cwd_root, _ = get_project_json_from_cwd()
3264
3315
  if not project_root and cwd_root:
@@ -3274,6 +3325,180 @@ def precheck(project_root, skip_checks, magic_drc, checks, dry_run):
3274
3325
  return
3275
3326
 
3276
3327
  project_json_path = project_root_path / '.cf' / 'project.json'
3328
+
3329
+ if poll and not remote:
3330
+ console.print("[red]✗[/red] --poll requires --remote.")
3331
+ raise SystemExit(1)
3332
+
3333
+ if remote:
3334
+ import time
3335
+ from urllib.parse import urlencode
3336
+
3337
+ import httpx as httpx_remote
3338
+ platform_id = _load_project_platform_id(str(project_root_path))
3339
+ if not platform_id:
3340
+ console.print(
3341
+ "[red]✗[/red] Link this repo to a platform project (set platform_project_id via [bold]cf link[/bold])."
3342
+ )
3343
+ raise SystemExit(1)
3344
+ try:
3345
+ verify_remote_precheck_repo(
3346
+ project_root_path,
3347
+ git_ref,
3348
+ checks=tuple(checks),
3349
+ skip_checks=tuple(skip_checks),
3350
+ )
3351
+ except RemotePrecheckGitError as e:
3352
+ console.print(f"[red]✗[/red] {e}")
3353
+ raise SystemExit(1)
3354
+ remote_params = [("git_ref", git_ref)]
3355
+ # Single checks= / skip_checks= value so proxies do not drop duplicate query keys.
3356
+ if checks:
3357
+ remote_params.append(("checks", ",".join(checks)))
3358
+ if skip_checks:
3359
+ remote_params.append(("skip_checks", ",".join(skip_checks)))
3360
+ if magic_drc:
3361
+ remote_params.append(("magic_drc", "true"))
3362
+ if dry_run:
3363
+ console.print(
3364
+ f"[cyan]Would POST[/cyan] /projects/{platform_id}/precheck-jobs?"
3365
+ + urlencode(remote_params)
3366
+ )
3367
+ return
3368
+ if poll and wait_timeout < 0:
3369
+ console.print(
3370
+ "[red]✗[/red] --wait-timeout must be >= 0 (0 means no limit while polling)."
3371
+ )
3372
+ raise SystemExit(1)
3373
+ config = load_user_config()
3374
+ api_key = config.get('api_key')
3375
+ if not api_key:
3376
+ console.print("[yellow]Not logged in.[/yellow] Run [bold]cf login[/bold] first.")
3377
+ raise SystemExit(1)
3378
+ api_url = _get_api_url()
3379
+ client = httpx_remote.Client(
3380
+ base_url=f"{api_url}/api/v1",
3381
+ headers={'Authorization': f'Bearer {api_key}'},
3382
+ timeout=120.0,
3383
+ )
3384
+ try:
3385
+ resp = client.post(
3386
+ f"/projects/{platform_id}/precheck-jobs",
3387
+ params=remote_params,
3388
+ )
3389
+ if resp.status_code == 401:
3390
+ console.print("[red]✗[/red] API key is invalid or expired. Run [bold]cf login[/bold].")
3391
+ raise SystemExit(1)
3392
+ if not resp.is_success:
3393
+ try:
3394
+ detail = resp.json().get("detail", resp.text)
3395
+ except Exception:
3396
+ detail = resp.text
3397
+ console.print(f"[red]✗[/red] {detail}")
3398
+ raise SystemExit(1)
3399
+ job = resp.json()
3400
+ jid = job["id"]
3401
+ st0 = job.get("status") or "unknown"
3402
+ if st0 == "failed":
3403
+ console.print(f"[cyan]Remote precheck[/cyan] job_id={jid} status={st0}")
3404
+ elif st0 == "running":
3405
+ console.print(f"[cyan]Remote precheck started[/cyan] job_id={jid} status={st0}")
3406
+ else:
3407
+ console.print(f"[cyan]Queued remote precheck[/cyan] job_id={jid} status={st0}")
3408
+ if job.get("status") == "failed" and job.get("error_message"):
3409
+ console.print(f"[red]✗[/red] {job['error_message']}")
3410
+ raise SystemExit(1)
3411
+ if job.get("status") == "completed":
3412
+ console.print("[green]✓[/green] Remote precheck completed")
3413
+ if job.get("github_pr_url"):
3414
+ console.print(f" Pull request: {job['github_pr_url']}")
3415
+ return
3416
+ if not poll:
3417
+ console.print(
3418
+ "[dim]Not waiting: use [bold]cf precheck --remote --poll[/bold] to stream progress "
3419
+ "([bold]--wait-timeout 0[/bold] = no time limit while polling).[/dim]"
3420
+ )
3421
+ return
3422
+ deadline = None if wait_timeout == 0 else time.monotonic() + wait_timeout
3423
+ if wait_timeout == 0:
3424
+ console.print("[dim]Polling until the job completes (no timeout).[/dim]")
3425
+ else:
3426
+ console.print(
3427
+ f"[dim]Polling every 5s; stops after {wait_timeout}s if still queued or running. "
3428
+ f"Use [bold]--wait-timeout 0[/bold] for no limit.[/dim]"
3429
+ )
3430
+ last_status_seen = st0
3431
+ terminal = None
3432
+ github_pr_url = None
3433
+ fail_message = None
3434
+ progress_emitted = 0
3435
+ console.print("[dim]Worker log batches appear below as the platform receives them (5s poll).[/dim]")
3436
+ while True:
3437
+ if deadline is not None and time.monotonic() > deadline:
3438
+ console.print(
3439
+ "[yellow]⚠[/yellow] Timed out waiting for remote precheck (job still queued or running)."
3440
+ )
3441
+ console.print(
3442
+ f"[dim]job_id={jid} — open the project in the portal or run [bold]cf status[/bold].[/dim]"
3443
+ )
3444
+ console.print(
3445
+ "[dim]Cancel a stuck run in the portal, or retry with e.g. "
3446
+ "[bold]cf precheck --remote --poll --wait-timeout 14400[/bold].[/dim]"
3447
+ )
3448
+ raise SystemExit(1)
3449
+ time.sleep(5)
3450
+ r2 = client.get(f"/projects/{platform_id}/precheck-jobs/{jid}")
3451
+ if r2.status_code == 401:
3452
+ console.print("[red]✗[/red] API key is invalid or expired.")
3453
+ raise SystemExit(1)
3454
+ r2.raise_for_status()
3455
+ j2 = r2.json()
3456
+ st = j2.get("status")
3457
+ prog = j2.get("progress")
3458
+ if isinstance(prog, list) and len(prog) > progress_emitted:
3459
+ for row in prog[progress_emitted:]:
3460
+ if not isinstance(row, dict):
3461
+ continue
3462
+ msg = row.get("message")
3463
+ if msg:
3464
+ det = row.get("details")
3465
+ if (
3466
+ isinstance(det, dict)
3467
+ and det.get("event") == "check_done"
3468
+ ):
3469
+ console.print(Text(str(msg), style="bold"))
3470
+ else:
3471
+ console.print(Text(str(msg), style="dim"))
3472
+ progress_emitted = len(prog)
3473
+ if st == "completed":
3474
+ terminal = "completed"
3475
+ github_pr_url = j2.get("github_pr_url")
3476
+ break
3477
+ if st == "failed":
3478
+ terminal = "failed"
3479
+ fail_message = j2.get("error_message") or "unknown error"
3480
+ break
3481
+ if st != last_status_seen:
3482
+ console.print(
3483
+ f"[dim]… job status[/dim] [cyan]{st or 'unknown'}[/cyan]"
3484
+ )
3485
+ last_status_seen = st
3486
+
3487
+ if terminal == "completed":
3488
+ console.print("[green]✓[/green] Remote precheck completed")
3489
+ if github_pr_url:
3490
+ console.print(f" Pull request: {github_pr_url}")
3491
+ elif terminal == "failed":
3492
+ console.print(f"[red]✗[/red] Remote precheck failed: {fail_message}")
3493
+ raise SystemExit(1)
3494
+ except SystemExit:
3495
+ raise
3496
+ except Exception as e:
3497
+ console.print(f"[red]✗[/red] Remote precheck request failed: {e}")
3498
+ raise SystemExit(1)
3499
+ finally:
3500
+ client.close()
3501
+ return
3277
3502
 
3278
3503
  with open(project_json_path, 'r') as f:
3279
3504
  project_data = json.load(f)
@@ -3319,12 +3544,14 @@ def precheck(project_root, skip_checks, magic_drc, checks, dry_run):
3319
3544
 
3320
3545
  if magic_drc:
3321
3546
  precheck_args.append('--magic-drc')
3322
-
3323
- if skip_checks:
3324
- precheck_args.extend(['--skip-checks'] + list(skip_checks))
3325
-
3547
+
3548
+ # Positional check names before --skip-checks (matches cf-precheck argparse; see
3549
+ # precheck-runner _cf_precheck_shell_cmd).
3326
3550
  if checks:
3327
3551
  precheck_args.extend(list(checks))
3552
+
3553
+ if skip_checks:
3554
+ precheck_args.extend(['--skip-checks'] + list(skip_checks))
3328
3555
 
3329
3556
  inner_cmd = 'pip3 install --upgrade -q --root-user-action=ignore cf-precheck 2>/dev/null && exec cf-precheck ' + ' '.join(precheck_args)
3330
3557
 
@@ -0,0 +1,228 @@
1
+ """
2
+ Git consistency checks before queueing remote precheck.
3
+
4
+ Ensures local HEAD matches the commit the platform will clone for --git-ref and that
5
+ working tree / index match that commit for precheck inputs (wrapper GDS, user_defines.v
6
+ when the GPIO check runs, and .cf/project.json when it is tracked).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import subprocess
13
+ from pathlib import Path
14
+ from typing import List, Optional, Set, Tuple
15
+
16
+
17
+ class RemotePrecheckGitError(Exception):
18
+ """Local repository state is not consistent with origin for remote precheck."""
19
+
20
+
21
+ _GDS_BASES: Tuple[Tuple[str, str], ...] = (
22
+ ("analog", "gds/user_analog_project_wrapper"),
23
+ ("digital", "gds/user_project_wrapper"),
24
+ ("openframe", "gds/openframe_project_wrapper"),
25
+ ("mini", "gds/user_project_wrapper_mini4"),
26
+ )
27
+ _GDS_SUFFIXES: Tuple[str, ...] = (".gds", ".gds.gz")
28
+
29
+ USER_DEFINES_REL = "verilog/rtl/user_defines.v"
30
+ CF_PROJECT_JSON_REL = ".cf/project.json"
31
+
32
+
33
+ def _run_git(repo: Path, *args: str, timeout: float = 120.0) -> subprocess.CompletedProcess[str]:
34
+ return subprocess.run(
35
+ ["git", *args],
36
+ cwd=str(repo),
37
+ capture_output=True,
38
+ text=True,
39
+ timeout=timeout,
40
+ )
41
+
42
+
43
+ def _resolve_origin_tip_sha(repo: Path, git_ref: str) -> str:
44
+ ref = git_ref.strip()
45
+ if not ref:
46
+ raise RemotePrecheckGitError("--git-ref must be a non-empty branch or tag name.")
47
+ r = _run_git(repo, "ls-remote", "origin", f"refs/heads/{ref}")
48
+ if r.returncode != 0:
49
+ raise RemotePrecheckGitError(
50
+ f"git ls-remote failed (is 'origin' configured and reachable?): {r.stderr.strip() or r.stdout}"
51
+ )
52
+ lines = [ln for ln in r.stdout.strip().splitlines() if ln.strip()]
53
+ if lines:
54
+ return lines[0].split()[0]
55
+ r2 = _run_git(repo, "ls-remote", "origin", f"refs/tags/{ref}")
56
+ if r2.returncode != 0:
57
+ raise RemotePrecheckGitError(
58
+ f"git ls-remote failed for tags: {r2.stderr.strip() or r2.stdout}"
59
+ )
60
+ lines2 = [ln for ln in r2.stdout.strip().splitlines() if ln.strip()]
61
+ if lines2:
62
+ return lines2[0].split()[0]
63
+ raise RemotePrecheckGitError(
64
+ f"No branch or tag {ref!r} found on origin. Push the ref or fix --git-ref."
65
+ )
66
+
67
+
68
+ def _local_head_sha(repo: Path) -> str:
69
+ r = _run_git(repo, "rev-parse", "HEAD")
70
+ if r.returncode != 0:
71
+ raise RemotePrecheckGitError(
72
+ f"Not a valid git checkout: {r.stderr.strip() or 'git rev-parse HEAD failed'}"
73
+ )
74
+ return r.stdout.strip()
75
+
76
+
77
+ def _detect_wrapper_gds(repo: Path) -> Tuple[str, str]:
78
+ """Return (project_kind, relative_path) for the single wrapper GDS, or raise."""
79
+ hits: list[Tuple[str, str]] = []
80
+ for kind, base in _GDS_BASES:
81
+ for suf in _GDS_SUFFIXES:
82
+ rel = base + suf
83
+ if (repo / rel).is_file():
84
+ hits.append((kind, rel))
85
+ break
86
+ if not hits:
87
+ raise RemotePrecheckGitError(
88
+ "No wrapper GDS found (expected exactly one of e.g. "
89
+ "gds/user_project_wrapper.gds, gds/user_analog_project_wrapper.gds, …). "
90
+ "Remote precheck requires the same layout as local cf-precheck."
91
+ )
92
+ if len(hits) > 1:
93
+ paths = ", ".join(h[1] for h in hits)
94
+ raise RemotePrecheckGitError(
95
+ f"Multiple wrapper GDS layouts found ({paths}). Remove extras so only one project type is present."
96
+ )
97
+ return hits[0]
98
+
99
+
100
+ def _load_cf_project_type(project_json: Path) -> Optional[str]:
101
+ if not project_json.is_file():
102
+ return None
103
+ try:
104
+ data = json.loads(project_json.read_text(encoding="utf-8"))
105
+ except (json.JSONDecodeError, OSError):
106
+ return None
107
+ t = data.get("project", {}).get("type")
108
+ return str(t).strip().lower() if t else None
109
+
110
+
111
+ def _gpio_defines_will_run(
112
+ checks: Tuple[str, ...],
113
+ skip_checks: Tuple[str, ...],
114
+ project_kind: str,
115
+ ) -> bool:
116
+ if project_kind not in ("analog", "digital"):
117
+ return False
118
+ cnorm = {x.strip().lower() for x in checks if x.strip()}
119
+ snorm = {x.strip().lower() for x in skip_checks if x.strip()}
120
+ if "gpio_defines" in snorm:
121
+ return False
122
+ if cnorm:
123
+ return "gpio_defines" in cnorm
124
+ return True
125
+
126
+
127
+ def _path_tracked_in_git(repo: Path, rel: str) -> bool:
128
+ r = _run_git(repo, "ls-files", "--error-unmatch", rel)
129
+ return r.returncode == 0
130
+
131
+
132
+ def _critical_precheck_paths(
133
+ repo: Path,
134
+ project_json: Path,
135
+ checks: Tuple[str, ...],
136
+ skip_checks: Tuple[str, ...],
137
+ ) -> Set[str]:
138
+ kind_gds, gds_rel = _detect_wrapper_gds(repo)
139
+ out: Set[str] = {gds_rel}
140
+
141
+ cf_type = _load_cf_project_type(project_json)
142
+ if cf_type and cf_type != kind_gds:
143
+ raise RemotePrecheckGitError(
144
+ f".cf/project.json type is {cf_type!r} but the wrapper GDS indicates {kind_gds!r}. "
145
+ "Fix project type or GDS layout before remote precheck."
146
+ )
147
+
148
+ if _gpio_defines_will_run(checks, skip_checks, kind_gds):
149
+ ud = repo / USER_DEFINES_REL
150
+ if ud.is_file() or _path_tracked_in_git(repo, USER_DEFINES_REL):
151
+ out.add(USER_DEFINES_REL)
152
+
153
+ if _path_tracked_in_git(repo, CF_PROJECT_JSON_REL):
154
+ out.add(CF_PROJECT_JSON_REL)
155
+
156
+ return out
157
+
158
+
159
+ def _porcelain_paths(repo: Path) -> List[str]:
160
+ r = _run_git(repo, "status", "--porcelain=v1", "-u")
161
+ if r.returncode != 0:
162
+ raise RemotePrecheckGitError(f"git status failed: {r.stderr.strip()}")
163
+ paths: List[str] = []
164
+ for line in r.stdout.splitlines():
165
+ if len(line) < 4:
166
+ continue
167
+ code = line[:2]
168
+ rest = line[3:].strip()
169
+ if " -> " in rest:
170
+ rest = rest.split(" -> ", 1)[-1]
171
+ if rest.startswith('"') and rest.endswith('"'):
172
+ rest = rest[1:-1].replace('\\"', '"')
173
+ if code == "??":
174
+ paths.append(f"??{rest}")
175
+ elif code.strip():
176
+ paths.append(rest)
177
+ return paths
178
+
179
+
180
+ def verify_remote_precheck_repo(
181
+ project_root: Path,
182
+ git_ref: str,
183
+ *,
184
+ checks: Tuple[str, ...],
185
+ skip_checks: Tuple[str, ...],
186
+ ) -> None:
187
+ """
188
+ Raise RemotePrecheckGitError unless origin/{git_ref} tip matches HEAD and precheck
189
+ input paths are clean and match that revision.
190
+ """
191
+ repo = project_root.resolve()
192
+ git_marker = repo / ".git"
193
+ if not (git_marker.is_dir() or git_marker.is_file()):
194
+ raise RemotePrecheckGitError(
195
+ "Remote precheck requires a git checkout with .git (clone your GitHub repo, not a plain folder copy)."
196
+ )
197
+
198
+ remote_sha = _resolve_origin_tip_sha(repo, git_ref)
199
+ head_sha = _local_head_sha(repo)
200
+ if head_sha != remote_sha:
201
+ raise RemotePrecheckGitError(
202
+ f"Local HEAD ({head_sha[:7]}) must match origin {git_ref!r} ({remote_sha[:7]}). "
203
+ f"git checkout {git_ref} && git pull, or push your commits, then retry."
204
+ )
205
+
206
+ project_json = repo / ".cf" / "project.json"
207
+ critical = _critical_precheck_paths(repo, project_json, checks, skip_checks)
208
+
209
+ dirty = _porcelain_paths(repo)
210
+ for entry in dirty:
211
+ if entry.startswith("??"):
212
+ path = entry[2:]
213
+ if path in critical:
214
+ raise RemotePrecheckGitError(
215
+ f"{path!r} is untracked but required for remote precheck. "
216
+ "Add and commit it (or remove it) so the remote clone matches your machine."
217
+ )
218
+ elif entry in critical:
219
+ raise RemotePrecheckGitError(
220
+ f"{entry!r} has uncommitted changes. Commit or stash before remote precheck."
221
+ )
222
+
223
+ for rel in sorted(critical):
224
+ r = _run_git(repo, "diff-index", "--quiet", "HEAD", "--", rel)
225
+ if r.returncode != 0:
226
+ raise RemotePrecheckGitError(
227
+ f"{rel!r} has uncommitted changes. Commit or stash before remote precheck."
228
+ )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "2.3.1"
3
+ version = "2.3.12"
4
4
  description = "CLI tool to automate ChipFoundry project submission to SFTP server"
5
5
  authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
6
6
  readme = "README.md"