gabriel / muse public
symbol_diff.py python
659 lines 26.8 KB
Raw
sha256:3767afb72520f9b56053bb98fd83d323f738ee4cad16e306e8cf6862608380e4 feat: first-class directory tracking across status, diff, r… Sonnet 4.6 minor ⚠ breaking 54 days ago
1 """Symbol-level diff engine for the code domain plugin.
2
3 Produces typed :class:`~muse.domain.DomainOp` entries at symbol granularity
4 rather than file granularity. This allows Muse to report which *functions*
5 were added, removed, renamed, or modified — not just which *files* changed.
6
7 Operation types produced
8 ------------------------
9 ``InsertOp``
10 A symbol was added to the file (new function, class, etc.).
11
12 ``DeleteOp``
13 A symbol was removed from the file.
14
15 ``ReplaceOp``
16 A symbol's content changed. The ``old_summary`` / ``new_summary`` fields
17 describe the nature of the change:
18
19 - ``"renamed to <name>"`` — same body, different name (rename detected
20 via matching ``body_hash``).
21 - ``"signature changed"`` — same body, different signature.
22 - ``"implementation changed"`` — same signature, different body.
23 - ``"modified"`` — both signature and body changed.
24
25 ``MoveOp``
26 Reserved for intra-file positional moves (used when a symbol's address
27 is unchanged but it is detected elsewhere via ``content_id``).
28
29 Cross-file move detection
30 -------------------------
31 When a symbol disappears from one file and appears in another with an
32 identical ``content_id``, the diff engine annotates the ``DeleteOp`` and
33 ``InsertOp`` ``content_summary`` fields to indicate the move direction. No
34 special op type is introduced — the existing :class:`~muse.domain.InsertOp`
35 and :class:`~muse.domain.DeleteOp` suffice because their addresses already
36 encode the file path.
37
38 Algorithm
39 ---------
40 1. Partition symbol addresses into ``added``, ``removed``, and ``common``.
41 2. Build ``body_hash → address`` reverse maps for added and removed sets.
42 3. For each ``removed`` symbol:
43
44 a. If ``content_id`` matches an ``added`` symbol → **exact move/copy**
45 (same name, same body, different file).
46 b. Else if ``body_hash`` matches an ``added`` symbol → **rename**
47 (same body, different name).
48
49 4. Emit ``ReplaceOp`` for renames; pair the cross-file move partners via
50 ``content_summary``.
51 5. Emit ``DeleteOp`` for genuinely removed symbols.
52 6. Emit ``InsertOp`` for genuinely added symbols.
53 7. Emit ``ReplaceOp`` for symbols whose ``content_id`` changed.
54 """
55
56 import logging
57
58 from muse.core.types import Manifest
59 from muse.domain import AddressedDeleteOp, AddressedInsertOp, DomainOp, PatchOp, RenameOp, ReplaceOp
60 from muse.plugins.code.ast_parser import SymbolTree
61
62 logger = logging.getLogger(__name__)
63
64 type ContentIdMap = dict[str, str] # content_id or body_hash → address
65 type SetHashMap = dict[str, set[str]] # path → set of body hashes
66 type FileMoveMap = dict[str, str] # old_path → new_path
67 type ContentTupleMap = dict[str, tuple[str, int, list[DomainOp]]] # content_id → (addr, idx, ops)
68 type SymbolTreeMap = dict[str, SymbolTree] # file_path → symbol tree
69
70 _CHILD_DOMAIN = "code_symbols"
71
72 # ---------------------------------------------------------------------------
73 # Symbol-level diff within a single file
74 # ---------------------------------------------------------------------------
75
76 def diff_symbol_trees(
77 base: SymbolTree,
78 target: SymbolTree,
79 ) -> list[DomainOp]:
80 """Compute symbol-level ops transforming *base* into *target*.
81
82 Both trees must be scoped to the same file (their addresses share the
83 same ``"<file_path>::"`` prefix).
84
85 Args:
86 base: Symbol tree of the base (older) version of the file.
87 target: Symbol tree of the target (newer) version of the file.
88
89 Returns:
90 Ordered list of :class:`~muse.domain.DomainOp` entries.
91 """
92 base_addrs = set(base)
93 target_addrs = set(target)
94 added: set[str] = target_addrs - base_addrs
95 removed: set[str] = base_addrs - target_addrs
96 common: set[str] = base_addrs & target_addrs
97
98 # Reverse maps for rename / move detection.
99 added_by_content: ContentIdMap = {
100 target[a]["content_id"]: a for a in added
101 }
102 added_by_body: ContentIdMap = {
103 target[a]["body_hash"]: a for a in added
104 }
105
106 ops: list[DomainOp] = []
107 # Addresses claimed by rename / move detection — excluded from plain ops.
108 matched_removed: set[str] = set()
109 matched_added: set[str] = set()
110
111 # ── Pass 1: renames (same body_hash, different name) ──────────────────
112 for rem_addr in sorted(removed):
113 base_rec = base[rem_addr]
114
115 if base_rec["content_id"] in added_by_content:
116 # Exact content match at a different address → same symbol moved
117 # (intra-file positional moves don't produce a different address;
118 # this catches cross-file moves surfaced within a single-file diff
119 # when the caller slices the tree incorrectly — uncommon).
120 tgt_addr = added_by_content[base_rec["content_id"]]
121 tgt_rec = target[tgt_addr]
122 ops.append(ReplaceOp(
123 op="replace",
124 address=rem_addr,
125 position=None,
126 old_content_id=base_rec["content_id"],
127 new_content_id=tgt_rec["content_id"],
128 old_summary=f"{base_rec['kind']} {base_rec['name']}",
129 new_summary=f"moved to {tgt_rec['qualified_name']}",
130 ))
131 matched_removed.add(rem_addr)
132 matched_added.add(tgt_addr)
133
134 elif (
135 base_rec["body_hash"] in added_by_body
136 and added_by_body[base_rec["body_hash"]] not in matched_added
137 ):
138 # Same body, different name → rename.
139 tgt_addr = added_by_body[base_rec["body_hash"]]
140 tgt_rec = target[tgt_addr]
141 ops.append(ReplaceOp(
142 op="replace",
143 address=rem_addr,
144 position=None,
145 old_content_id=base_rec["content_id"],
146 new_content_id=tgt_rec["content_id"],
147 old_summary=f"{base_rec['kind']} {base_rec['name']}",
148 new_summary=f"renamed to {tgt_rec['name']}",
149 ))
150 matched_removed.add(rem_addr)
151 matched_added.add(tgt_addr)
152
153 # ── Pass 2: plain deletions ────────────────────────────────────────────
154 for rem_addr in sorted(removed - matched_removed):
155 rec = base[rem_addr]
156 ops.append(AddressedDeleteOp(
157 op="delete",
158 address=rem_addr,
159 content_id=rec["content_id"],
160 content_summary=(
161 f"removed {rec['kind']} {rec['name']}"
162 f" L{rec['lineno']}–{rec['end_lineno']}"
163 ),
164 ))
165
166 # ── Pass 3: plain additions ────────────────────────────────────────────
167 for add_addr in sorted(added - matched_added):
168 rec = target[add_addr]
169 ops.append(AddressedInsertOp(
170 op="insert",
171 address=add_addr,
172 content_id=rec["content_id"],
173 content_summary=(
174 f"added {rec['kind']} {rec['name']}"
175 f" L{rec['lineno']}–{rec['end_lineno']}"
176 ),
177 ))
178
179 # ── Pass 4: modifications ──────────────────────────────────────────────
180 for addr in sorted(common):
181 base_rec = base[addr]
182 tgt_rec = target[addr]
183 if base_rec["content_id"] == tgt_rec["content_id"]:
184 continue # unchanged
185
186 loc = f" L{tgt_rec['lineno']}–{tgt_rec['end_lineno']}"
187 if base_rec["body_hash"] == tgt_rec["body_hash"]:
188 old_summary = f"{base_rec['kind']} {base_rec['name']} (signature changed)"
189 new_summary = f"{tgt_rec['kind']} {tgt_rec['name']} (signature updated){loc}"
190 elif base_rec["signature_id"] == tgt_rec["signature_id"]:
191 old_summary = f"{base_rec['kind']} {base_rec['name']} (implementation)"
192 new_summary = f"{tgt_rec['kind']} {tgt_rec['name']} (implementation changed){loc}"
193 else:
194 old_summary = f"{base_rec['kind']} {base_rec['name']}"
195 new_summary = f"{tgt_rec['kind']} {tgt_rec['name']} (modified){loc}"
196
197 ops.append(ReplaceOp(
198 op="replace",
199 address=addr,
200 position=None,
201 old_content_id=base_rec["content_id"],
202 new_content_id=tgt_rec["content_id"],
203 old_summary=old_summary,
204 new_summary=new_summary,
205 ))
206
207 return ops
208
209 # ---------------------------------------------------------------------------
210 # Cross-file diff: build the full op list for a snapshot pair
211 # ---------------------------------------------------------------------------
212
213 def build_diff_ops(
214 base_files: Manifest,
215 target_files: Manifest,
216 base_trees: SymbolTreeMap,
217 target_trees: SymbolTreeMap,
218 ) -> list[DomainOp]:
219 """Build the complete op list transforming *base* snapshot into *target*.
220
221 For each changed file:
222
223 - **No symbol trees available**: coarse ``InsertOp`` / ``DeleteOp`` /
224 ``ReplaceOp`` at file level.
225 - **Symbol trees available for both sides**: ``PatchOp`` with symbol-level
226 ``child_ops``. If all symbols are unchanged (formatting-only change)
227 a ``ReplaceOp`` with ``"reformatted"`` summary is emitted instead.
228 - **Symbol tree available for one side only** (new or deleted file):
229 ``PatchOp`` listing each symbol individually.
230
231 Cross-file move annotation
232 --------------------------
233 After building per-file ops, a second pass checks whether any symbol
234 ``content_id`` appears in both a ``DeleteOp`` child op and an ``InsertOp``
235 child op across *different* files. When found, both ops' ``content_summary``
236 fields are annotated with the move direction.
237
238 Args:
239 base_files: ``{path: raw_bytes_hash}`` from the base snapshot.
240 target_files: ``{path: raw_bytes_hash}`` from the target snapshot.
241 base_trees: Symbol trees for changed base files, keyed by path.
242 target_trees: Symbol trees for changed target files, keyed by path.
243
244 Returns:
245 Ordered list of ``DomainOp`` entries.
246 """
247 base_paths = set(base_files)
248 target_paths = set(target_files)
249 added_paths = sorted(target_paths - base_paths)
250 removed_paths = sorted(base_paths - target_paths)
251 modified_paths = sorted(
252 p for p in base_paths & target_paths
253 if base_files[p] != target_files[p]
254 )
255
256 # Detect file-level move+edits before emitting per-file ops so we can
257 # suppress the plain added/removed ops for those paths.
258 move_map = _detect_file_move_edits(
259 added_paths, removed_paths, base_trees, target_trees,
260 base_files=base_files, target_files=target_files,
261 )
262 moved_old = set(move_map)
263 moved_new = set(move_map.values())
264
265 ops: list[DomainOp] = []
266
267 # ── Added files (excluding move+edit targets) ──────────────────────────
268 for path in added_paths:
269 if path in moved_new:
270 continue
271 tree = target_trees.get(path, {})
272 if tree:
273 child_ops: list[DomainOp] = [
274 AddressedInsertOp(
275 op="insert",
276 address=addr,
277 content_id=rec["content_id"],
278 content_summary=(
279 f"added {rec['kind']} {rec['name']}"
280 f" L{rec['lineno']}–{rec['end_lineno']}"
281 ),
282 )
283 for addr, rec in sorted(tree.items())
284 ]
285 ops.append(_patch(path, child_ops, file_change="added"))
286 else:
287 ops.append(AddressedInsertOp(
288 op="insert",
289 address=path,
290 content_id=target_files[path],
291 content_summary=f"added {path}",
292 ))
293
294 # ── Removed files (excluding move+edit sources) ────────────────────────
295 for path in removed_paths:
296 if path in moved_old:
297 continue
298 tree = base_trees.get(path, {})
299 if tree:
300 child_ops = [
301 AddressedDeleteOp(
302 op="delete",
303 address=addr,
304 content_id=rec["content_id"],
305 content_summary=(
306 f"removed {rec['kind']} {rec['name']}"
307 f" L{rec['lineno']}–{rec['end_lineno']}"
308 ),
309 )
310 for addr, rec in sorted(tree.items())
311 ]
312 ops.append(_patch(path, child_ops, file_change="deleted"))
313 else:
314 ops.append(AddressedDeleteOp(
315 op="delete",
316 address=path,
317 content_id=base_files[path],
318 content_summary=f"removed {path}",
319 ))
320
321 # ── Modified files ─────────────────────────────────────────────────────
322 for path in modified_paths:
323 base_tree = base_trees.get(path, {})
324 target_tree = target_trees.get(path, {})
325
326 if base_tree or target_tree:
327 child_ops = diff_symbol_trees(base_tree, target_tree)
328 if child_ops:
329 ops.append(_patch(path, child_ops, file_change="modified"))
330 else:
331 # All symbols have the same content_id — formatting-only change.
332 ops.append(ReplaceOp(
333 op="replace",
334 address=path,
335 position=None,
336 old_content_id=base_files[path],
337 new_content_id=target_files[path],
338 old_summary=f"{path} (before)",
339 new_summary=f"{path} (reformatted — no semantic change)",
340 ))
341 else:
342 ops.append(ReplaceOp(
343 op="replace",
344 address=path,
345 position=None,
346 old_content_id=base_files[path],
347 new_content_id=target_files[path],
348 old_summary=f"{path} (before)",
349 new_summary=f"{path} (after)",
350 ))
351
352 # ── Move+edit files ────────────────────────────────────────────────────
353 for old_path, new_path in sorted(move_map.items()):
354 old_tree = base_trees.get(old_path, {})
355 new_tree = target_trees.get(new_path, {})
356 # Strip the file-path prefix (everything up to and including "::") so
357 # that "math_utils.py::add" and "core_math.py::add" both normalise to
358 # "add" and land in the *common* bucket of diff_symbol_trees. Without
359 # this, every symbol looks deleted-and-added, producing spurious
360 # "moved to <name>" entries for functions that are actually unchanged.
361 old_tree_norm: SymbolTree = {
362 addr.split("::", 1)[-1]: rec for addr, rec in old_tree.items()
363 }
364 new_tree_norm: SymbolTree = {
365 addr.split("::", 1)[-1]: rec for addr, rec in new_tree.items()
366 }
367 child_ops = diff_symbol_trees(old_tree_norm, new_tree_norm)
368
369 n_added = sum(1 for o in child_ops if o["op"] == "insert")
370 n_removed = sum(1 for o in child_ops if o["op"] == "delete")
371 n_modified = sum(1 for o in child_ops if o["op"] == "replace")
372 sym_parts: list[str] = []
373 if n_added:
374 sym_parts.append(f"{n_added} added")
375 if n_removed:
376 sym_parts.append(f"{n_removed} removed")
377 if n_modified:
378 sym_parts.append(f"{n_modified} modified")
379 ops.append(RenameOp(op="rename", address=new_path, from_address=old_path))
380 if child_ops:
381 child_summary = f"moved from {old_path}"
382 if sym_parts:
383 child_summary += f"; {', '.join(sym_parts)}"
384 ops.append(PatchOp(
385 op="patch",
386 address=new_path,
387 child_ops=child_ops,
388 child_domain=_CHILD_DOMAIN,
389 child_summary=child_summary,
390 ))
391
392 _annotate_cross_file_moves(ops)
393 return ops
394
395 def _patch(
396 path: str,
397 child_ops: list[DomainOp],
398 file_change: str,
399 ) -> PatchOp:
400 """Wrap symbol child_ops in a file-level PatchOp.
401
402 ``file_change`` must be one of ``"added"``, ``"deleted"``, or
403 ``"modified"`` and is set by the caller based on which path bucket the
404 file came from — *not* inferred from child op direction. This prevents
405 the renderer from misclassifying a living file that lost all its symbols
406 as a deleted file.
407 """
408 n_added = sum(1 for o in child_ops if o["op"] == "insert")
409 n_removed = sum(1 for o in child_ops if o["op"] == "delete")
410 n_modified = sum(1 for o in child_ops if o["op"] == "replace")
411 parts: list[str] = []
412 if n_added:
413 parts.append(f"{n_added} symbol{'s' if n_added > 1 else ''} added")
414 if n_removed:
415 parts.append(f"{n_removed} symbol{'s' if n_removed > 1 else ''} removed")
416 if n_modified:
417 parts.append(f"{n_modified} symbol{'s' if n_modified > 1 else ''} modified")
418 summary = ", ".join(parts) if parts else "no symbol changes"
419 return PatchOp(
420 op="patch",
421 address=path,
422 child_ops=child_ops,
423 child_domain=_CHILD_DOMAIN,
424 child_summary=summary,
425 file_change=file_change,
426 )
427
428 def _detect_file_move_edits(
429 added_paths: list[str],
430 removed_paths: list[str],
431 base_trees: SymbolTreeMap,
432 target_trees: SymbolTreeMap,
433 min_overlap: float = 0.5,
434 base_files: Manifest | None = None,
435 target_files: Manifest | None = None,
436 ) -> FileMoveMap:
437 """Return a mapping of old_path → new_path for file-level move+edits.
438
439 Two detection strategies run in priority order:
440
441 1. **Symbol-tree overlap** — files whose symbol trees share at least
442 ``min_overlap`` fraction of ``body_hash`` values are considered
443 moved-and-edited. Confidence score = overlap fraction (0.5–1.0).
444
445 2. **Blob content_id match** (fallback) — when neither the deleted nor
446 the added file has a parseable symbol tree, compare their raw blob
447 ``content_id`` values. An exact match (score = 1.0) means the file
448 content is identical: the path changed but nothing else did. This
449 covers plain-text files, unknown extensions, and any file the AST
450 adapter cannot parse.
451
452 Each old_path and new_path is used at most once (greedy, highest-score
453 pair wins when multiple candidates exist).
454
455 Args:
456 added_paths: Paths present in target but not in base.
457 removed_paths: Paths present in base but not in target.
458 base_trees: Symbol trees for changed base files.
459 target_trees: Symbol trees for changed target files.
460 min_overlap: Minimum body_hash overlap fraction for strategy 1.
461 base_files: Raw blob manifest for base snapshot (strategy 2).
462 target_files: Raw blob manifest for target snapshot (strategy 2).
463
464 Returns:
465 ``{old_path: new_path}`` for each detected move+edit pair.
466 """
467 base_hashes: SetHashMap = {
468 p: {rec["body_hash"] for rec in base_trees[p].values()}
469 for p in removed_paths
470 if p in base_trees and base_trees[p]
471 }
472 target_hashes: SetHashMap = {
473 p: {rec["body_hash"] for rec in target_trees[p].values()}
474 for p in added_paths
475 if p in target_trees and target_trees[p]
476 }
477
478 # Strategy 1: symbol-tree overlap — score = overlap fraction.
479 candidates: list[tuple[float, str, str]] = []
480 for old_path, old_h in base_hashes.items():
481 for new_path, new_h in target_hashes.items():
482 common = old_h & new_h
483 if not common:
484 continue
485 overlap = len(common) / min(len(old_h), len(new_h))
486 if overlap >= min_overlap:
487 candidates.append((overlap, old_path, new_path))
488
489 # Strategy 2: blob content_id match for files with no symbol tree.
490 # Score = 1.0 (exact match) so these rank above any symbol-overlap candidate.
491 if base_files is not None and target_files is not None:
492 tree_covered_old = set(base_hashes)
493 tree_covered_new = set(target_hashes)
494 # Build content_id → [new_path] map for added files without a symbol tree.
495 blob_added: dict[str, list[str]] = {}
496 for p in added_paths:
497 if p in tree_covered_new:
498 continue
499 cid = target_files.get(p)
500 if cid:
501 blob_added.setdefault(cid, []).append(p)
502 for old_path in removed_paths:
503 if old_path in tree_covered_old:
504 continue
505 cid = base_files.get(old_path)
506 if cid and cid in blob_added:
507 for new_path in blob_added[cid]:
508 candidates.append((1.0, old_path, new_path))
509
510 candidates.sort(key=lambda t: t[0], reverse=True)
511
512 moves: FileMoveMap = {}
513 used_old: set[str] = set()
514 used_new: set[str] = set()
515 for _, old_path, new_path in candidates:
516 if old_path in used_old or new_path in used_new:
517 continue
518 moves[old_path] = new_path
519 used_old.add(old_path)
520 used_new.add(new_path)
521
522 return moves
523
524 def _annotate_cross_file_moves(ops: list[DomainOp]) -> None:
525 """Annotate DeleteOp/InsertOp pairs that represent cross-file symbol moves.
526
527 Mutates the ``content_summary`` of matching ops in place. A move is
528 detected when:
529
530 - A ``DeleteOp`` child op (inside a ``PatchOp``) has the same
531 ``content_id`` as an ``InsertOp`` child op in a *different* file's
532 ``PatchOp``.
533
534 This is a best-effort annotation pass — it does not change the semantic
535 meaning of the ops, only their human-readable summaries.
536 """
537 # Collect child op references: content_id → (file_path, op_index_in_patch)
538 # We need mutable access so work with lists rather than immutable tuples.
539 delete_by_content: ContentTupleMap = {}
540 insert_by_content: ContentTupleMap = {}
541
542 for op in ops:
543 if op["op"] != "patch":
544 continue
545 file_path = op["address"]
546 for i, child in enumerate(op["child_ops"]):
547 if child["op"] == "delete":
548 delete_by_content[child["content_id"]] = (file_path, i, op["child_ops"])
549 elif child["op"] == "insert":
550 insert_by_content[child["content_id"]] = (file_path, i, op["child_ops"])
551
552 for content_id, (del_file, del_idx, del_children) in delete_by_content.items():
553 if content_id not in insert_by_content:
554 continue
555 ins_file, ins_idx, ins_children = insert_by_content[content_id]
556 if del_file == ins_file:
557 continue # Same file — not a cross-file move.
558
559 del_op = del_children[del_idx]
560 ins_op = ins_children[ins_idx]
561 # Narrow to the expected op kinds before accessing kind-specific fields.
562 if del_op["op"] != "delete" or ins_op["op"] != "insert":
563 continue
564 # Annotate both sides with move direction.
565 del_children[del_idx] = AddressedDeleteOp(
566 op="delete",
567 address=del_op["address"],
568 content_id=del_op["content_id"],
569 content_summary=f"{del_op['content_summary']} → moved to {ins_file}",
570 )
571 ins_children[ins_idx] = AddressedInsertOp(
572 op="insert",
573 address=ins_op["address"],
574 content_id=ins_op["content_id"],
575 content_summary=f"{ins_op['content_summary']} ← moved from {del_file}",
576 )
577
578 # ---------------------------------------------------------------------------
579 # Summary helpers
580 # ---------------------------------------------------------------------------
581
582 def delta_summary(ops: list[DomainOp]) -> str:
583 """Produce a human-readable one-line summary of a list of ops.
584
585 Counts file-level and symbol-level operations separately.
586
587 Args:
588 ops: Top-level op list from :func:`build_diff_ops`.
589
590 Returns:
591 A concise summary string (e.g. ``"2 files modified (5 symbols)"``)
592 or ``"no changes"`` for an empty list.
593 """
594 # Directory ops: address ends with "/" (trailing slash is the canonical signal).
595 dirs_added = sum(1 for o in ops if o["op"] == "insert" and o["address"].endswith("/"))
596 dirs_removed = sum(1 for o in ops if o["op"] == "delete" and o["address"].endswith("/"))
597 dirs_renamed = sum(1 for o in ops if o["op"] == "rename" and "::" not in o["address"] and o["address"].endswith("/"))
598
599 files_added = sum(1 for o in ops if o["op"] == "insert" and "::" not in o["address"] and not o["address"].endswith("/"))
600 files_removed = sum(1 for o in ops if o["op"] == "delete" and "::" not in o["address"] and not o["address"].endswith("/"))
601 files_renamed = sum(1 for o in ops if o["op"] == "rename" and "::" not in o["address"] and not o["address"].endswith("/"))
602 files_modified = sum(
603 1 for o in ops
604 if o["op"] in ("replace", "patch") and "::" not in o["address"]
605 )
606
607 # Count child-level symbol ops.
608 symbols_added = 0
609 symbols_removed = 0
610 symbols_modified = 0
611 for op in ops:
612 if op["op"] == "patch":
613 for child in op["child_ops"]:
614 if child["op"] == "insert":
615 symbols_added += 1
616 elif child["op"] == "delete":
617 symbols_removed += 1
618 elif child["op"] in ("replace", "move"):
619 symbols_modified += 1
620 elif op["op"] == "replace" and "::" not in op["address"]:
621 pass
622
623 parts: list[str] = []
624
625 dir_parts: list[str] = []
626 if dirs_added:
627 dir_parts.append(f"{dirs_added} added")
628 if dirs_removed:
629 dir_parts.append(f"{dirs_removed} removed")
630 if dirs_renamed:
631 dir_parts.append(f"{dirs_renamed} renamed")
632 if dir_parts:
633 total_dirs = dirs_added + dirs_removed + dirs_renamed
634 parts.append(f"{', '.join(dir_parts)} director{'ies' if total_dirs != 1 else 'y'}")
635
636 file_parts: list[str] = []
637 if files_added:
638 file_parts.append(f"{files_added} added")
639 if files_removed:
640 file_parts.append(f"{files_removed} removed")
641 if files_modified:
642 file_parts.append(f"{files_modified} modified")
643 if files_renamed:
644 file_parts.append(f"{files_renamed} renamed")
645 if file_parts:
646 total_files = files_added + files_removed + files_modified + files_renamed
647 parts.append(f"{', '.join(file_parts)} file{'s' if total_files != 1 else ''}")
648
649 sym_parts: list[str] = []
650 if symbols_added:
651 sym_parts.append(f"{symbols_added} added")
652 if symbols_removed:
653 sym_parts.append(f"{symbols_removed} removed")
654 if symbols_modified:
655 sym_parts.append(f"{symbols_modified} modified")
656 if sym_parts:
657 parts.append(f"{', '.join(sym_parts)} symbol{'s' if sum([symbols_added, symbols_removed, symbols_modified]) != 1 else ''}")
658
659 return ", ".join(parts) if parts else "no changes"
File History 1 commit
sha256:3767afb72520f9b56053bb98fd83d323f738ee4cad16e306e8cf6862608380e4 feat: first-class directory tracking across status, diff, r… Sonnet 4.6 minor 54 days ago