88from typing import List , Optional , Set
99from importlib_resources import files , as_file
1010
11- from xircuits .utils .pathing import resolve_working_dir , components_base_dir
12- from .core_libs import is_core_library
13-
1411from xircuits .utils .pathing import (
1512 resolve_working_dir ,
1613 resolve_library_dir ,
1714 normalize_library_slug ,
15+ components_base_dir ,
1816)
1917from xircuits .utils .git_toml_manager import (
2018 read_component_metadata_entry ,
3028from xircuits .utils .venv_ops import install_specs
3129from xircuits .handlers .request_remote import get_remote_config
3230
31+ from .core_libs import is_core_library
32+
3333
3434@dataclass
3535class SourceSpec :
@@ -70,7 +70,7 @@ def update_library(
7070 dry_run: If True, compute actions and print a unified diff (no files changed).
7171 prune: If True, also archive local-only files/dirs (rename to *.bak).
7272 install_deps: If True (default), update per-library extra and install its requirements.
73- use_latest: If True, ignore metadata ref and pull latest from default branch.
73+ use_latest: If True, ignore metadata ref and pull latest from default branch.
7474 no_overwrite: If True, skip updating files with local modifications.
7575
7676 Returns:
@@ -105,7 +105,6 @@ def update_library(
105105 # Regular (non-core) library update from git
106106 return _update_from_git (
107107 lib_name = lib_name ,
108- working_dir = working_dir ,
109108 repo = repo ,
110109 ref = ref ,
111110 dry_run = dry_run ,
@@ -160,7 +159,7 @@ def _update_from_wheel(
160159
161160 # Sync files
162161 if is_base_file :
163- report = _sync_single_file (src_path , dst_path , dry_run , timestamp , no_overwrite ) # <-- ADD no_overwrite
162+ report = _sync_single_file (src_path , dst_path , dry_run , timestamp , no_overwrite )
164163 else :
165164 report = _sync_with_backups (
166165 source_root = src_path ,
@@ -205,7 +204,6 @@ def _update_from_wheel(
205204
206205def _update_from_git (
207206 lib_name : str ,
208- working_dir : Path ,
209207 repo : Optional [str ],
210208 ref : Optional [str ],
211209 dry_run : bool ,
@@ -218,6 +216,10 @@ def _update_from_git(
218216 Update a regular component library from its git repository.
219217 (Original update_library logic)
220218 """
219+ working_dir = resolve_working_dir ()
220+ if working_dir is None :
221+ raise RuntimeError ("Xircuits working directory not found. Run 'xircuits init' first." )
222+
221223 dest_dir = resolve_library_dir (lib_name )
222224 if not dest_dir .exists () or not dest_dir .is_dir ():
223225 raise FileNotFoundError (
@@ -233,25 +235,22 @@ def _update_from_git(
233235 "Ensure it was installed (so metadata exists) or present in your index.json."
234236 )
235237
236- print (f"Updating '{ lib_name } ' from { source_spec .repo_url } @ { source_spec .desired_ref or 'default' } ..." )
238+ print (
239+ f"Updating { lib_name } from { source_spec .repo_url } "
240+ f"{ '(ref=' + source_spec .desired_ref + ')' if source_spec .desired_ref else '(default branch)' } "
241+ )
237242
238243 temp_repo_dir = Path (tempfile .mkdtemp (prefix = f"update_{ lib_name } _" ))
239244 try :
240- clone_from_github_url (
241- library_url = source_spec .repo_url ,
242- target_dir = str (temp_repo_dir ),
243- ref = source_spec .desired_ref
244- )
245+ git_clone_shallow (source_spec .repo_url , temp_repo_dir )
246+ if source_spec .desired_ref :
247+ git_checkout_ref (temp_repo_dir , source_spec .desired_ref )
245248
246- temp_lib_dir = temp_repo_dir / lib_name
247- if not temp_lib_dir .exists ():
248- raise FileNotFoundError (
249- f"No '{ lib_name } ' subdirectory in cloned repository. "
250- "Ensure the library name matches the folder in the repo."
251- )
249+ repo_url_final , resolved_ref , is_tag = get_git_metadata (str (temp_repo_dir ))
250+ src_dir = _select_library_source_dir (temp_repo_dir , lib_name )
252251
253- sync_report = _sync_with_backups (
254- source_root = temp_lib_dir ,
252+ report = _sync_with_backups (
253+ source_root = src_dir ,
255254 destination_root = dest_dir ,
256255 dry_run = dry_run ,
257256 prune = prune ,
@@ -260,14 +259,12 @@ def _update_from_git(
260259 )
261260
262261 # On DRY-RUN: show and save a unified diff of planned changes.
263- # This is an *in-memory* comparison; no filesystem writes occur, and we don't
264- # ever touch '/dev/null'. For adds/deletes we diff against an empty side.
265262 if dry_run :
266263 diff_text = _build_combined_diff (
267- temp_lib_dir , dest_dir ,
268- added = sync_report .added ,
269- updated = sync_report .updated ,
270- deleted = sync_report .deleted
264+ src_dir , dest_dir ,
265+ added = report .added ,
266+ updated = report .updated ,
267+ deleted = report .deleted
271268 )
272269 if diff_text .strip ():
273270 diff_path = dest_dir / f"{ lib_name } .update.{ timestamp } .dry-run.diff.txt"
@@ -278,21 +275,51 @@ def _update_from_git(
278275
279276 # Update pyproject metadata / deps (skipped during dry-run)
280277 if not dry_run :
281- if repo :
282- write_component_metadata_entry (lib_name , repo , source_spec .desired_ref )
278+ try :
279+ record_component_metadata (
280+ library_name = lib_name ,
281+ member_path = str (dest_dir ),
282+ repo_url = repo_url_final or source_spec .repo_url ,
283+ ref = resolved_ref or source_spec .desired_ref or "latest" ,
284+ is_tag = is_tag ,
285+ )
286+ except Exception as e :
287+ print (f"Warning: could not update pyproject metadata: { e } " )
288+
289+ # Requirements / extras install
290+ try :
291+ reqs = read_requirements_for_library (dest_dir )
292+ except Exception as e :
293+ reqs = []
294+ print (f"Warning: could not read requirements for { lib_name } : { e } " )
295+
296+ # Always refresh the per-library extra + meta extra on update
297+ try :
298+ set_library_extra (lib_name , reqs )
299+ rebuild_meta_extra ("xai-components" )
300+ except Exception as e :
301+ print (f"Warning: could not update optional-dependencies for { lib_name } : { e } " )
283302
284303 if install_deps :
285- reqs_list = read_requirements_for_library (dest_dir )
286- if reqs_list :
287- print (f"Installing requirements for { lib_name } ..." )
288- install_per_library_extra (lib_name , reqs_list )
289- else :
290- print (f"No requirements.txt found for { lib_name } ; skipping dependency install." )
304+ try :
305+ if reqs :
306+ print (f"Installing Python dependencies for { lib_name } ..." )
307+ install_specs (reqs )
308+ print (f"✓ Dependencies for { lib_name } installed." )
309+ else :
310+ print (f"No requirements.txt entries for { lib_name } ; nothing to install." )
311+ except Exception as e :
312+ print (f"Warning: installing dependencies for { lib_name } failed:{ e } " .rstrip ())
313+
314+ try :
315+ regenerate_lock_file ()
316+ except Exception as e :
317+ print (f"Warning: could not regenerate lock file: { e } " )
291318
292319 summary = (
293320 f"{ lib_name } update "
294- f"(added: { len (sync_report .added )} , updated: { len (sync_report .updated )} , "
295- f"deleted: { len (sync_report .deleted )} , unchanged: { len (sync_report .unchanged )} )"
321+ f"(added: { len (report .added )} , updated: { len (report .updated )} , "
322+ f"deleted: { len (report .deleted )} , unchanged: { len (report .unchanged )} )"
296323 )
297324 return summary
298325 finally :
@@ -328,7 +355,13 @@ def _extract_base_py_from_wheel(dest_dir: Path) -> None:
328355 raise RuntimeError (f"Failed to extract base.py from wheel: { e } " )
329356
330357
331- def _sync_single_file (src_file : Path , dst_file : Path , dry_run : bool , timestamp : str , no_overwrite : bool = False ) -> SyncReport :
358+ def _sync_single_file (
359+ src_file : Path ,
360+ dst_file : Path ,
361+ dry_run : bool ,
362+ timestamp : str ,
363+ no_overwrite : bool = False
364+ ) -> SyncReport :
332365 """
333366 Sync a single file with backup support.
334367 """
@@ -494,83 +527,85 @@ def _sync_with_backups(
494527 no_overwrite : bool = False ,
495528) -> SyncReport :
496529 """
497- Synchronize source_root into destination_root.
498-
499- - Added files/dirs are copied.
500- - Modified files: backed up then overwritten (unless no_overwrite=True).
501- - Local-only files: left alone unless prune=True.
502- - If no_overwrite=True, skip updating files that differ locally.
503- """
504- report = SyncReport (added = [], updated = [], deleted = [], unchanged = [])
505-
506- src_files = _gather_files_recursive (source_root )
507- dst_files = _gather_files_recursive (destination_root )
530+ Perform the filesystem sync (or simulate it on dry_run).
508531
509- src_rel = {p .relative_to (source_root ) for p in src_files }
510- dst_rel = {p .relative_to (destination_root ) for p in dst_files }
532+ Prints concise markers:
533+ +++ path
534+ --- path (backup: <name>) for updated/deleted
535+ --- path (would backup) for updated/deleted (dry-run mode)
536+ ⊙ path (local changes preserved) when no_overwrite=True
537+ """
538+ added : List [str ] = []
539+ updated : List [str ] = []
540+ deleted : List [str ] = []
541+ unchanged : List [str ] = []
511542
512- added_rel = src_rel - dst_rel
513- common_rel = src_rel & dst_rel
514- local_only = dst_rel - src_rel
543+ source_files = _walk_files (source_root )
544+ destination_files = _walk_files (destination_root )
515545
516- # 1) Added files
517- for rel in sorted (added_rel ):
546+ # Add / update
547+ for rel in sorted (source_files , key = str ):
518548 src = source_root / rel
519549 dst = destination_root / rel
520- report .added .append (str (rel ))
521- print (f"+++ { rel } " )
522- if not dry_run :
523- dst .parent .mkdir (parents = True , exist_ok = True )
524- shutil .copy2 (src , dst )
550+ path_str = rel .as_posix ()
525551
526- # 2) Common files
527- for rel in sorted (common_rel ):
528- src = source_root / rel
529- dst = destination_root / rel
530552 if not dst .exists ():
531- report .added .append (str (rel ))
532- print (f"+++ { rel } " )
533- if not dry_run :
534- dst .parent .mkdir (parents = True , exist_ok = True )
535- shutil .copy2 (src , dst )
536- elif src .is_file () and dst .is_file ():
537- if _files_equal (src , dst ):
538- report .unchanged .append (str (rel ))
539- else :
540- # File differs - local modification detected
541- if no_overwrite :
542- print (f"⊙ { rel } (local changes preserved)" )
543- report .unchanged .append (str (rel ))
544- elif dry_run :
545- backup_name = _backup_in_place (dst , timestamp , dry_run )
546- report .updated .append (str (rel ))
547- print (f"--- { rel } (would backup as: { backup_name } )" )
548- print (f"+++ { rel } " )
549- else :
550- backup_name = _backup_in_place (dst , timestamp , dry_run )
551- report .updated .append (str (rel ))
552- print (f"--- { rel } (backup: { backup_name } )" )
553- print (f"+++ { rel } " )
554- shutil .copy2 (src , dst )
553+ print (f"+++ { path_str } " )
554+ _copy_file (src , dst , dry_run )
555+ added .append (path_str )
556+ continue
557+
558+ if _files_equal (src , dst ):
559+ unchanged .append (path_str )
560+ continue
561+
562+ # File differs - check no_overwrite flag
563+ if no_overwrite :
564+ print (f"⊙ { path_str } (local changes preserved)" )
565+ unchanged .append (path_str )
566+ continue
567+
568+ if dry_run :
569+ backup_name = _backup_in_place (dst , timestamp , dry_run )
570+ print (f"--- { path_str } (would backup as: { backup_name } )" )
571+ print (f"+++ { path_str } " )
555572 else :
556- report .unchanged .append (str (rel ))
573+ backup_name = _backup_in_place (dst , timestamp , dry_run )
574+ print (f"--- { path_str } (backup: { backup_name } )" )
575+ print (f"+++ { path_str } " )
576+ _copy_file (src , dst , dry_run )
577+ updated .append (path_str )
557578
558- # 3) Local -only files (prune if requested)
579+ # Deletions (dest -only) — only when prune=True
559580 if prune :
560- for rel in sorted (local_only ):
581+ source_dirs = _walk_dirs (source_root )
582+ destination_dirs = _walk_dirs (destination_root )
583+
584+ for rel in sorted (destination_files - source_files , key = str ):
561585 dst = destination_root / rel
562- if dst .exists ():
586+ path_str = rel .as_posix ()
587+ if dry_run :
588+ backup_name = _backup_in_place (dst , timestamp , dry_run )
589+ print (f"--- { path_str } (would backup as: { backup_name } )" )
590+ else :
563591 backup_name = _backup_in_place (dst , timestamp , dry_run )
564- report .deleted .append (str (rel ))
592+ print (f"--- { path_str } (backup: { backup_name } )" )
593+ deleted .append (path_str )
594+
595+ # Directories only in destination — deepest first
596+ for rel in sorted (destination_dirs - source_dirs , key = lambda p : len (p .as_posix ()), reverse = True ):
597+ dst_dir = destination_root / rel
598+ if dst_dir .exists ():
599+ path_str = rel .as_posix () + "/"
565600 if dry_run :
566- print (f"--- { rel } (would prune as: { backup_name } )" )
601+ backup_name = _backup_in_place (dst_dir , timestamp , dry_run )
602+ print (f"--- { path_str } (would backup as: { backup_name } )" )
567603 else :
568- print (f"--- { rel } (pruned: { backup_name } )" )
569- else :
570- for rel in sorted (local_only ):
571- report .unchanged .append (str (rel ))
604+ backup_name = _backup_in_place (dst_dir , timestamp , dry_run )
605+ print (f"--- { path_str } (backup: { backup_name } )" )
606+ deleted .append (path_str )
572607
573- return report
608+ return SyncReport ( added = added , updated = updated , deleted = deleted , unchanged = unchanged )
574609
575610# ---------- Diff helpers (for dry-run) ----------
576611
0 commit comments