gabriel / muse public
importer.py python
736 lines 24.5 KB
Raw
sha256:a317886dc0496c4af7b285b3e41c86c4c34ea2e79afc63b8829aadb1ada7903f chore: bump version to 0.2.0rc15 to match musehub#113 fix release Sonnet 4.6 patch 65 days ago
1 """Bridge import engine — Git → Muse commit replay.
2
3 Orchestrates reading git commits via :mod:`.git_primitives` and writing
4 Muse commit/snapshot records into the object store.
5
6 Exports
7 -------
8 _replay_commit Convert one git commit to a Muse CommitRecord
9 _replay_branch Replay all commits on a git branch into Muse
10 _import_tags Import git tags as Muse tag records
11 run_git_import CLI entry point for ``muse bridge git-import``
12 """
13
14 from __future__ import annotations
15
16 import argparse
17 import datetime
18 import json
19 import pathlib
20 import subprocess
21 import sys
22 from typing import Any
23
24 from muse.core.bridge.git_primitives import (
25 AttributionMapper,
26 _CatFile,
27 _batch_commit_log,
28 _batch_diff_tree,
29 _git,
30 _is_lfs_pointer,
31 _list_git_branches,
32 _list_git_tags,
33 _parse_sem_ver_bump,
34 _should_exclude,
35 _strip_ansi,
36 )
37 from muse.core.bridge.state import (
38 BridgeState,
39 SnapshotManifest,
40 read_bridge_state,
41 write_bridge_state,
42 )
43 from muse.core.errors import ExitCode
44 from muse.core.paths import muse_dir, ref_path
45 from muse.core.types import now_utc_iso
46
47 # GitCommitInfo and _CatFile are imported above; type alias here for clarity
48 from muse.core.bridge.git_primitives import GitCommitInfo
49
50
51 def _replay_commit(
52 root: pathlib.Path,
53 git_dir: pathlib.Path,
54 commit_info: GitCommitInfo,
55 current_manifest: SnapshotManifest,
56 cat_file: _CatFile,
57 attribution: AttributionMapper,
58 branch: str,
59 extra_excludes: list[str],
60 dry_run: bool,
61 lfs_skip: bool,
62 preserve_merge: bool,
63 ) -> tuple[str, dict[str, str]]:
64 """Convert one git commit to a Muse CommitRecord + SnapshotRecord.
65
66 Reads file blobs from *git_dir* via *cat_file*, updates *current_manifest*
67 incrementally (add / modify / delete), and writes the snapshot and commit
68 records to the Muse object store at *root*.
69
70 Conventional commit prefixes in the message are parsed to set
71 ``sem_ver_bump`` (``feat:`` → minor, ``fix:`` → patch, breaking → major).
72
73 Args:
74 root: Muse repository root.
75 git_dir: Git repository root.
76 commit_info: Commit metadata dict from :func:`_batch_commit_log`.
77 current_manifest: In-progress manifest (mutated in-place).
78 cat_file: Long-running cat-file process.
79 attribution: Email → handle mapper.
80 branch: Muse branch name to write to.
81 extra_excludes: Extra path patterns to exclude.
82 dry_run: If True, skip all disk writes.
83 lfs_skip: If True, skip LFS pointer files.
84 preserve_merge: If True, record both parent IDs for merge commits.
85
86 Returns:
87 ``(muse_commit_id, updated_manifest)`` — the new manifest after applying
88 this commit's changes.
89 """
90 from muse.core.object_store import write_object
91 from muse.core.commits import CommitRecord, write_commit
92 from muse.core.snapshots import SnapshotRecord, write_snapshot
93 from muse.core.ids import hash_commit, hash_snapshot
94 from muse.core.types import blob_id
95
96 sha = commit_info["sha"]
97 parent_shas = commit_info["parent_shas"]
98 author_email = commit_info["author_email"]
99 author_name = commit_info["author_name"]
100 author_date_str = commit_info["author_date"]
101 subject = _strip_ansi(commit_info["subject"])
102 body = _strip_ansi(commit_info["body"])
103
104 message = subject
105 if body:
106 message = f"{subject}\n\n{body}"
107
108 author_handle = attribution.get_handle(author_email, author_name)
109 sem_ver_bump = _parse_sem_ver_bump(message)
110
111 parent_sha = parent_shas[0] if parent_shas else None
112 diff = _batch_diff_tree(git_dir, sha, parent_sha)
113
114 new_manifest = dict(current_manifest)
115 for path, blob_sha in diff.items():
116 if _should_exclude(path, extra_excludes):
117 continue
118 if blob_sha is None:
119 new_manifest.pop(path, None)
120 else:
121 content = cat_file.read(blob_sha)
122 if lfs_skip and _is_lfs_pointer(content):
123 continue
124 obj_id = blob_id(content)
125 if not dry_run:
126 write_object(root, obj_id, content)
127 new_manifest[path] = obj_id
128
129 try:
130 committed_at = datetime.datetime.fromisoformat(author_date_str)
131 except (ValueError, TypeError):
132 committed_at = datetime.datetime.now(datetime.timezone.utc)
133
134 snapshot_id = hash_snapshot(new_manifest)
135 snapshot = SnapshotRecord(
136 snapshot_id=snapshot_id,
137 manifest=new_manifest,
138 directories=[],
139 created_at=committed_at,
140 note=f"git-import:{sha}",
141 )
142
143 commit_id = hash_commit(
144 parent_ids=[],
145 snapshot_id=snapshot_id,
146 message=message,
147 committed_at_iso=committed_at.isoformat(),
148 author=author_handle,
149 )
150
151 commit_record = CommitRecord(
152 commit_id=commit_id,
153 branch=branch,
154 snapshot_id=snapshot_id,
155 message=message,
156 committed_at=committed_at,
157 parent_commit_id=None, # set by caller after chaining
158 parent2_commit_id=None,
159 author=author_handle,
160 sem_ver_bump=sem_ver_bump,
161 agent_id="git-import",
162 model_id="",
163 metadata={"git_sha": sha},
164 )
165
166 if not dry_run:
167 write_snapshot(root, snapshot)
168 write_commit(root, commit_record)
169
170 return commit_id, new_manifest
171
172
173 def _replay_branch(
174 root: pathlib.Path,
175 git_dir: pathlib.Path,
176 branch: str,
177 muse_branch: str,
178 from_sha: str | None,
179 attribution: AttributionMapper,
180 extra_excludes: list[str],
181 dry_run: bool,
182 lfs_skip: bool,
183 preserve_merge: bool,
184 verbose: bool,
185 json_out: bool,
186 ) -> tuple[int, str | None]:
187 """Orchestrate replay of all commits on a git branch into Muse.
188
189 Calls :func:`_batch_commit_log` to get the ordered commit list, then
190 iterates through each commit calling :func:`_replay_commit`. After each
191 commit the Muse branch ref is advanced and a reflog entry appended.
192
193 Args:
194 root: Muse repository root.
195 git_dir: Git repository root.
196 branch: Git branch name to import from.
197 muse_branch: Muse branch name to write to.
198 from_sha: If given, only import commits after this SHA.
199 attribution: Email → handle mapper.
200 extra_excludes: Extra path patterns to exclude.
201 dry_run: If True, report but do not write.
202 lfs_skip: If True, skip LFS pointer files.
203 preserve_merge: If True, record merge commit parents.
204 verbose: If True, print per-commit progress to stderr.
205 json_out: If True, emit NDJSON events.
206
207 Returns:
208 ``(commits_written, last_muse_commit_id)`` where *last_muse_commit_id*
209 is ``None`` when no commits were written.
210 """
211 from muse.core.refs import write_branch_ref
212 from muse.core.commits import commit_exists, read_commit, CommitRecord, write_commit
213 from muse.core.snapshots import read_snapshot, SnapshotRecord, write_snapshot
214 from muse.core.ids import hash_commit, hash_snapshot
215 from muse.core.reflog import append_reflog
216
217 _branch_ref = ref_path(root, muse_branch)
218 prev_muse_commit_id: str | None = None
219 if _branch_ref.exists():
220 content = _branch_ref.read_text().strip()
221 if content:
222 prev_muse_commit_id = content
223
224 current_manifest: dict[str, str] = {}
225 if from_sha is not None and prev_muse_commit_id:
226 existing_commit = read_commit(root, prev_muse_commit_id)
227 if existing_commit:
228 snapshot = read_snapshot(root, existing_commit.snapshot_id)
229 if snapshot:
230 current_manifest = dict(snapshot.manifest)
231
232 commits = _batch_commit_log(git_dir, branch, from_sha)
233 if not commits:
234 return 0, prev_muse_commit_id
235
236 commits_written = 0
237 last_muse_id: str | None = prev_muse_commit_id if from_sha is not None else None
238 prev_git_sha_to_muse: dict[str, str] = {}
239
240 with _CatFile(git_dir) as cat_file:
241 for commit_info in commits:
242 git_sha = commit_info["sha"]
243
244 muse_commit_id, current_manifest = _replay_commit(
245 root=root,
246 git_dir=git_dir,
247 commit_info=commit_info,
248 current_manifest=current_manifest,
249 cat_file=cat_file,
250 attribution=attribution,
251 branch=muse_branch,
252 extra_excludes=extra_excludes,
253 dry_run=dry_run,
254 lfs_skip=lfs_skip,
255 preserve_merge=preserve_merge,
256 )
257
258 if not dry_run and last_muse_id:
259 cr = read_commit(root, muse_commit_id)
260 if cr and cr.parent_commit_id is None:
261 from muse.core.ids import hash_commit as _ccid
262 new_id = _ccid(
263 parent_ids=[last_muse_id],
264 snapshot_id=cr.snapshot_id,
265 message=cr.message,
266 committed_at_iso=cr.committed_at.isoformat(),
267 author=cr.author,
268 )
269 cr2 = CommitRecord(
270 commit_id=new_id,
271 branch=cr.branch,
272 snapshot_id=cr.snapshot_id,
273 message=cr.message,
274 committed_at=cr.committed_at,
275 parent_commit_id=last_muse_id,
276 parent2_commit_id=cr.parent2_commit_id,
277 author=cr.author,
278 sem_ver_bump=cr.sem_ver_bump,
279 agent_id=cr.agent_id,
280 model_id=cr.model_id,
281 metadata=cr.metadata,
282 )
283 try:
284 write_commit(root, cr2)
285 muse_commit_id = new_id
286 except Exception:
287 pass
288
289 if not dry_run:
290 try:
291 write_branch_ref(root, muse_branch, muse_commit_id)
292 append_reflog(
293 root,
294 muse_branch,
295 last_muse_id,
296 muse_commit_id,
297 author=commit_info.get("author_name", "git-import"),
298 operation=f"git-import: {git_sha[:12]}",
299 )
300 except Exception:
301 pass
302
303 prev_git_sha_to_muse[git_sha] = muse_commit_id
304 last_muse_id = muse_commit_id
305 commits_written += 1
306
307 if verbose:
308 print(
309 f" [{commits_written}] {git_sha[:8]} → {muse_commit_id[:23]}… "
310 f"{commit_info['subject'][:60]}",
311 file=sys.stderr,
312 )
313 if json_out:
314 print(json.dumps({
315 "event": "commit",
316 "n": commits_written,
317 "git_sha": git_sha,
318 "muse_commit_id": muse_commit_id,
319 "message": commit_info["subject"][:120],
320 }))
321
322 return commits_written, last_muse_id
323
324
325 def _import_tags(
326 root: pathlib.Path,
327 git_dir: pathlib.Path,
328 cat_file: _CatFile,
329 muse_commit_id_map: dict[str, str],
330 ) -> int:
331 """Import git tags as Muse tags where a mapping exists.
332
333 For each git tag, looks up the git SHA in *muse_commit_id_map* to find
334 the corresponding Muse commit. Creates a :class:`TagRecord` for matched
335 tags. Tags whose SHA has no Muse counterpart are skipped.
336
337 Args:
338 root: Muse repository root.
339 git_dir: Git repository root.
340 cat_file: Long-running cat-file process.
341 muse_commit_id_map: Mapping from git SHA to Muse commit ID.
342
343 Returns:
344 Number of tags imported.
345 """
346 from muse.core.tags import TagRecord, compute_tag_id, write_tag
347 from muse.core.repo import read_repo_id
348
349 repo_id = read_repo_id(root)
350 git_tags = _list_git_tags(git_dir)
351 imported = 0
352
353 for tag_info in git_tags:
354 tag_name = tag_info["name"]
355 try:
356 sha = _git(git_dir, "rev-list", "-1", tag_name).strip()
357 except subprocess.CalledProcessError:
358 continue
359 muse_commit_id = muse_commit_id_map.get(sha)
360 if not muse_commit_id:
361 continue
362 tag_id = compute_tag_id(repo_id, muse_commit_id, tag_name)
363 tag = TagRecord(
364 tag_id=tag_id,
365 repo_id=repo_id,
366 commit_id=muse_commit_id,
367 tag=tag_name,
368 message=f"git-import: {tag_name}",
369 )
370 try:
371 write_tag(root, tag)
372 imported += 1
373 except Exception:
374 pass
375
376 return imported
377
378
379 def run_git_import(args: argparse.Namespace) -> None:
380 """Entry point for ``muse bridge git-import``.
381
382 Replays commits from a git repository into a Muse repository.
383
384 Incremental import reads ``.muse/git-bridge.toml`` to discover the last
385 imported git SHA and only imports commits introduced since that point.
386 Full import (default) replays the complete branch history.
387
388 Args:
389 args: Parsed argparse namespace; see the CLI register function for
390 flag definitions.
391
392 Raises:
393 SystemExit(ExitCode.USER_ERROR): No valid git repository at *source*.
394 SystemExit(ExitCode.USER_ERROR): ``--from-ref`` does not exist in git.
395 SystemExit(ExitCode.INTERNAL_ERROR): Object store I/O failure.
396 """
397 from muse.core.repo import find_repo_root, read_repo_id
398 from muse.core.paths import init_repo_dirs
399
400 source = pathlib.Path(args.source or ".").resolve()
401 json_out: bool = args.json_out
402 dry_run: bool = getattr(args, "dry_run", False)
403 verbose: bool = getattr(args, "verbose", False)
404 incremental: bool = getattr(args, "incremental", False)
405 all_branches: bool = getattr(args, "all_branches", False)
406 from_ref: str | None = getattr(args, "from_ref", None)
407 lfs_skip: bool = getattr(args, "lfs_skip", False)
408 preserve_merge: bool = getattr(args, "preserve_merge_commits", False)
409 no_init: bool = getattr(args, "no_init", False)
410 domain: str = getattr(args, "domain", "code")
411 import_tags: bool = getattr(args, "import_tags", False)
412 extra_excludes: list[str] = getattr(args, "excludes", []) or []
413 attr_map_path: pathlib.Path | None = (
414 pathlib.Path(args.attribution_map) if args.attribution_map else None
415 )
416 branch_args: list[str] = getattr(args, "branches", []) or []
417
418 target_arg = getattr(args, "target", None)
419 if target_arg:
420 target = pathlib.Path(target_arg).resolve()
421 else:
422 target = find_repo_root() or pathlib.Path.cwd()
423
424 if not (source / ".git").exists():
425 msg = f"No git repository found at {source}"
426 if json_out:
427 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
428 else:
429 print(f"Error: {msg}", file=sys.stderr)
430 raise SystemExit(ExitCode.USER_ERROR)
431
432 if not muse_dir(target).exists():
433 if no_init:
434 msg = f"No Muse repository at {target} and --no-init was set."
435 if json_out:
436 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
437 else:
438 print(f"Error: {msg}", file=sys.stderr)
439 raise SystemExit(ExitCode.USER_ERROR)
440 from muse.cli.app import main as _muse_main
441 import os as _os
442 _saved = _os.getcwd()
443 try:
444 target.mkdir(parents=True, exist_ok=True)
445 _os.chdir(target)
446 try:
447 _muse_main(["init", "--domain", domain])
448 except SystemExit:
449 pass
450 finally:
451 _os.chdir(_saved)
452
453 bridge_state = read_bridge_state(target)
454 from_sha: str | None = from_ref
455
456 if incremental and not from_sha:
457 last_import = bridge_state.get("last_import", {})
458 from_sha = last_import.get("git_sha") or None
459
460 if from_sha:
461 try:
462 _git(source, "cat-file", "-t", from_sha, check=True)
463 except subprocess.CalledProcessError:
464 msg = f"--from-ref {from_sha!r} does not exist in the git repository."
465 if json_out:
466 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
467 else:
468 print(f"Error: {msg}", file=sys.stderr)
469 raise SystemExit(ExitCode.USER_ERROR)
470
471 if all_branches:
472 git_branches = _list_git_branches(source)
473 elif branch_args:
474 git_branches = branch_args
475 else:
476 available = _list_git_branches(source)
477 if "main" in available:
478 git_branches = ["main"]
479 elif "master" in available:
480 git_branches = ["master"]
481 elif available:
482 git_branches = [available[0]]
483 else:
484 git_branches = []
485
486 attribution = AttributionMapper(attr_map_path)
487
488 total_commits_written = 0
489 last_git_sha: str | None = None
490 last_muse_commit_id: str | None = None
491 last_muse_branch: str | None = None
492 muse_commit_id_map: dict[str, str] = {}
493
494 for branch in git_branches:
495 muse_branch = branch
496
497 if verbose:
498 print(f"Importing branch {branch!r} → {muse_branch!r}…", file=sys.stderr)
499
500 n, last_id = _replay_branch(
501 root=target,
502 git_dir=source,
503 branch=branch,
504 muse_branch=muse_branch,
505 from_sha=from_sha if len(git_branches) == 1 else None,
506 attribution=attribution,
507 extra_excludes=extra_excludes,
508 dry_run=dry_run,
509 lfs_skip=lfs_skip,
510 preserve_merge=preserve_merge,
511 verbose=verbose,
512 json_out=json_out,
513 )
514 total_commits_written += n
515 if last_id:
516 last_muse_commit_id = last_id
517 last_muse_branch = muse_branch
518
519 if git_branches:
520 try:
521 last_git_sha = _git(source, "rev-parse", git_branches[0]).strip()
522 except subprocess.CalledProcessError:
523 last_git_sha = None
524
525 tags_imported = 0
526 if import_tags and not dry_run:
527 with _CatFile(source) as cf:
528 tags_imported = _import_tags(target, source, cf, muse_commit_id_map)
529
530 if not dry_run and last_git_sha and last_muse_commit_id:
531 new_state: BridgeState = {
532 "last_import": {
533 "git_sha": last_git_sha,
534 "git_ref": git_branches[0] if git_branches else "",
535 "git_remote": "",
536 "muse_branch": last_muse_branch or "",
537 "muse_commit_id": last_muse_commit_id,
538 "imported_at": now_utc_iso(),
539 "commits_written": total_commits_written,
540 },
541 "last_export": dict(bridge_state.get("last_export", {})),
542 }
543 try:
544 write_bridge_state(target, new_state)
545 except Exception:
546 pass
547
548 rerere_imported = 0
549 stashes_imported = 0
550
551 if getattr(args, "import_rerere", False):
552 rerere_confidence: float = getattr(args, "rerere_confidence", 0.7)
553 try:
554 from muse.core.bridge.harmony_shelf import import_rerere_to_harmony
555 rerere_imported = import_rerere_to_harmony(
556 target, source, confidence=rerere_confidence, dry_run=dry_run
557 )
558 except (FileNotFoundError, ImportError):
559 rerere_imported = 0
560
561 if getattr(args, "import_stashes", False):
562 try:
563 from muse.core.bridge.harmony_shelf import import_stashes_to_shelf
564 stashes_imported = import_stashes_to_shelf(target, source, dry_run=dry_run)
565 except ImportError:
566 stashes_imported = 0
567
568 if json_out:
569 print(json.dumps({
570 "event": "done",
571 "source": str(source),
572 "target": str(target),
573 "dry_run": dry_run,
574 "total_commits_written": total_commits_written,
575 "tags_imported": tags_imported,
576 "bridge_state_updated": not dry_run and bool(last_muse_commit_id),
577 "rerere_imported": rerere_imported,
578 "stashes_imported": stashes_imported,
579 }))
580 elif verbose or total_commits_written > 0:
581 print(
582 f"Imported {total_commits_written} commits from {source.name!r} → Muse "
583 f"({'dry run' if dry_run else 'done'})"
584 )
585
586
587 def _register_git_import_parser(
588 subs: "argparse._SubParsersAction[argparse.ArgumentParser]",
589 ) -> None:
590 """Register the ``git-import`` subcommand onto *subs*."""
591 import argparse as _ap
592
593 p = subs.add_parser(
594 "git-import",
595 help="Import commits from a git repository into a Muse repository.",
596 formatter_class=_ap.RawDescriptionHelpFormatter,
597 description=(
598 "Replay a git repository's commit history into a Muse repository.\n"
599 "Use --incremental to only import commits since the last run."
600 ),
601 )
602 p.add_argument(
603 "source",
604 nargs="?",
605 default=None,
606 metavar="SOURCE",
607 help="Path to the git repository to import from (default: current directory).",
608 )
609 p.add_argument(
610 "--target",
611 default=None,
612 metavar="PATH",
613 help="Path to the Muse repository to write into (default: current directory).",
614 )
615 p.add_argument(
616 "--branch",
617 action="append",
618 dest="branches",
619 default=[],
620 metavar="BRANCH",
621 help="Git branch to import (may be repeated; default: main).",
622 )
623 p.add_argument(
624 "--all",
625 action="store_true",
626 default=False,
627 dest="all_branches",
628 help="Import all local git branches.",
629 )
630 p.add_argument(
631 "--from-ref",
632 default=None,
633 metavar="SHA|TAG|BRANCH",
634 help="Start import from this git ref (for partial / incremental runs).",
635 )
636 p.add_argument(
637 "--incremental",
638 action="store_true",
639 default=False,
640 help="Auto-detect the last imported SHA from .muse/git-bridge.toml.",
641 )
642 p.add_argument(
643 "--attribution-map",
644 default=None,
645 metavar="PATH",
646 help="JSON file mapping git author emails to Muse handles.",
647 )
648 p.add_argument(
649 "--sign",
650 action="store_true",
651 default=False,
652 help="MSign every created Muse commit with an Ed25519 signature.",
653 )
654 p.add_argument(
655 "--preserve-merge-commits",
656 action="store_true",
657 default=False,
658 help="Record git merge commits as Muse commits with two parent IDs.",
659 )
660 p.add_argument(
661 "--lfs-skip",
662 action="store_true",
663 default=False,
664 help="Silently skip git LFS pointer files instead of importing them.",
665 )
666 p.add_argument(
667 "--lfs-fetch",
668 action="store_true",
669 default=False,
670 help="Download git LFS objects before importing.",
671 )
672 p.add_argument(
673 "--no-init",
674 action="store_true",
675 default=False,
676 help="Do not run 'muse init' if .muse/ is missing.",
677 )
678 p.add_argument(
679 "--domain",
680 default="code",
681 metavar="DOMAIN",
682 help="Muse domain for a newly initialised repository (default: code).",
683 )
684 p.add_argument(
685 "--import-tags",
686 action="store_true",
687 default=False,
688 help="Import git tags as Muse tags after importing commits.",
689 )
690 p.add_argument(
691 "--exclude",
692 action="append",
693 dest="excludes",
694 default=[],
695 metavar="PATTERN",
696 help="Glob patterns for files to exclude from the import (repeatable).",
697 )
698 p.add_argument(
699 "--dry-run",
700 action="store_true",
701 default=False,
702 help="Report what would be created without writing anything.",
703 )
704 p.add_argument(
705 "--verbose", "-v",
706 action="store_true",
707 default=False,
708 help="Print per-commit progress to stderr.",
709 )
710 p.add_argument(
711 "--json", "-j",
712 dest="json_out",
713 action="store_true",
714 default=False,
715 help="Emit NDJSON events (one JSON object per line).",
716 )
717 p.add_argument(
718 "--import-rerere",
719 action="store_true",
720 default=False,
721 help="Import git rerere conflict resolutions into Muse Harmony patterns.",
722 )
723 p.add_argument(
724 "--rerere-confidence",
725 type=float,
726 default=0.7,
727 metavar="FLOAT",
728 help="Confidence score for rerere-imported resolutions (default: 0.7).",
729 )
730 p.add_argument(
731 "--import-stashes",
732 action="store_true",
733 default=False,
734 help="Import git stashes as Muse shelf entries.",
735 )
736 p.set_defaults(func=run_git_import)
File History 1 commit
sha256:a317886dc0496c4af7b285b3e41c86c4c34ea2e79afc63b8829aadb1ada7903f chore: bump version to 0.2.0rc15 to match musehub#113 fix release Sonnet 4.6 patch 65 days ago