Skip to content

Reference

API and internal module reference for Seev. This section will be populated with mkdocstrings in later phases.

Prompts

Server-side assistant prompts available via MCP (overview; detailed docs to be generated in a later phase).

  • worklog_entry — generate a structured worklog for a date or range This section is generated via mkdocstrings for the core Python APIs. Use the page ToC to navigate types and functions. See also: MCP Tools and Prompts.

Configuration

Configuration management for Seev.

Phase 2 additions: read-only handling of DB/Markdown-related environment flags. - SEEV_DB_PATH: optional filesystem path to the SQLite database file. - SEEV_DB_AUTOWRITE: when truthy, certain integrations may persist data automatically. - SEEV_MD_PATH: optional filesystem path to the Markdown worklog file.

Also supports a lightweight seev.toml file with keys: - track_emails = ["..."] - track_repositories = ["..."] - db_path = "..." # new: database path - markdown_path = "..." # new: markdown file path

Backward compatibility: - Legacy glin.toml configuration files are still read if seev.toml is not present.

create_config_file(emails, config_path=None)

Create a glin.toml configuration file with the specified emails.

Parameters:

Name Type Description Default
emails list[str]

List of email addresses to track

required
config_path Path | None

Optional path for config file. Defaults to ./glin.toml

None

Returns:

Type Description
Path

Path to the created configuration file

Source code in seev/config.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def create_config_file(emails: list[str], config_path: Path | None = None) -> Path:
    """
    Create a glin.toml configuration file with the specified emails.

    Args:
        emails: List of email addresses to track
        config_path: Optional path for config file. Defaults to ./glin.toml

    Returns:
        Path to the created configuration file
    """
    if config_path is None:
        config_path = Path.cwd() / "glin.toml"

    config_path.parent.mkdir(parents=True, exist_ok=True)

    # Create TOML content
    emails_array = "[" + ", ".join(f'"{email}"' for email in emails) + "]"
    content = f"""# Glin Configuration
# List of email addresses to track commits from
track_emails = {emails_array}
"""

    config_path.write_text(content)
    return config_path

get_db_autowrite()

Return True if SEEV_DB_AUTOWRITE is a truthy value.

Accepted truthy values (case-insensitive): '1', 'true', 'yes', 'on'.

Source code in seev/config.py
196
197
198
199
200
201
202
203
204
def get_db_autowrite() -> bool:
    """Return True if SEEV_DB_AUTOWRITE is a truthy value.

    Accepted truthy values (case-insensitive): '1', 'true', 'yes', 'on'.
    """
    val = os.getenv("SEEV_DB_AUTOWRITE")
    if val is None:
        return False
    return val.strip().lower() in {"1", "true", "yes", "on"}

get_db_path()

Return the DB path to use for SQLite storage.

Precedence: 1. SEEV_DB_PATH if set (respects exact value, including ":memory:") 2. seev.toml key db_path (with glin.toml fallback) 3. Sensible default: ~/.seev/db.sqlite3

This function is read-only; it does not create files or directories.

Source code in seev/config.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def get_db_path() -> str:
    """Return the DB path to use for SQLite storage.

    Precedence:
    1. SEEV_DB_PATH if set (respects exact value, including ":memory:")
    2. seev.toml key `db_path` (with glin.toml fallback)
    3. Sensible default: ~/.seev/db.sqlite3

    This function is read-only; it does not create files or directories.
    """
    value = os.getenv("SEEV_DB_PATH")
    if value and value.strip():
        return value.strip()
    file_val = _get_config_file_value("db_path")
    if file_val and file_val.strip():
        return file_val.strip()
    # Default to a stable path in user home for CI and local runs.
    return "~/.seev/db.sqlite3"

get_markdown_path()

Return the Markdown worklog path.

Precedence: 1. SEEV_MD_PATH if set 2. seev.toml key markdown_path (with glin.toml fallback) 3. Default: ./WORKLOG.md

Source code in seev/config.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
def get_markdown_path() -> str:
    """Return the Markdown worklog path.

    Precedence:
    1. SEEV_MD_PATH if set
    2. seev.toml key `markdown_path` (with glin.toml fallback)
    3. Default: ./WORKLOG.md
    """
    value = os.getenv("SEEV_MD_PATH")
    if value and value.strip():
        return value.strip()
    file_val = _get_config_file_value("markdown_path")
    if file_val and file_val.strip():
        return file_val.strip()
    return "WORKLOG.md"

get_tracked_emails()

Get the list of email addresses to track commits from.

Priority order: 1. SEEV_TRACK_EMAILS environment variable (comma-separated) 2. seev.toml configuration file (with glin.toml fallback) 3. Git user.email configuration 4. Git user.name configuration (as fallback)

Returns:

Type Description
list[str]

List of email addresses/patterns to track. Empty list if none configured.

Source code in seev/config.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def get_tracked_emails() -> list[str]:
    """
    Get the list of email addresses to track commits from.

    Priority order:
    1. SEEV_TRACK_EMAILS environment variable (comma-separated)
    2. seev.toml configuration file (with glin.toml fallback)
    3. Git user.email configuration
    4. Git user.name configuration (as fallback)

    Returns:
        List of email addresses/patterns to track. Empty list if none configured.
    """
    # 1. Check environment variable first
    env_emails = os.getenv("SEEV_TRACK_EMAILS")
    if env_emails:
        return [email.strip() for email in env_emails.split(",") if email.strip()]

    # 2. Check for configuration file
    config_emails = _get_config_file_emails()
    if config_emails:
        return config_emails

    # 3. Fallback to git configuration
    git_pattern = _get_git_author_pattern()
    if git_pattern:
        return [git_pattern]

    return []

get_tracked_repositories()

Return the list of repositories to include when building worklogs.

Accepted formats per entry: - Local filesystem path to a Git repo - GitHub shorthand "owner/repo" - Full Git remote URL (https or ssh)

Precedence: 1. SEEV_TRACK_REPOSITORIES (comma-separated) [fallback: SEEV_TRACK_REPOS] 2. seev.toml key track_repositories = ["..."] (with glin.toml fallback)

Source code in seev/config.py
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
def get_tracked_repositories() -> list[str]:
    """
    Return the list of repositories to include when building worklogs.

    Accepted formats per entry:
    - Local filesystem path to a Git repo
    - GitHub shorthand "owner/repo"
    - Full Git remote URL (https or ssh)

    Precedence:
    1. SEEV_TRACK_REPOSITORIES (comma-separated) [fallback: SEEV_TRACK_REPOS]
    2. seev.toml key track_repositories = ["..."] (with glin.toml fallback)
    """
    env_val = os.getenv("SEEV_TRACK_REPOSITORIES") or os.getenv("SEEV_TRACK_REPOS")
    if env_val:
        return [v.strip() for v in env_val.split(",") if v.strip()]
    file_repos = _get_config_file_repositories()
    if file_repos:
        return file_repos
    return []

set_tracked_emails_env(emails)

Set the SEEV_TRACK_EMAILS environment variable.

Parameters:

Name Type Description Default
emails list[str]

List of email addresses to track

required
Source code in seev/config.py
119
120
121
122
123
124
125
126
127
def set_tracked_emails_env(emails: list[str]) -> None:
    """
    Set the SEEV_TRACK_EMAILS environment variable.

    Args:
        emails: List of email addresses to track
    """
    value = ",".join(emails)
    os.environ["SEEV_TRACK_EMAILS"] = value

set_tracked_repositories_env(repositories)

Set SEEV_TRACK_REPOSITORIES environment variable to a comma-separated list.

Source code in seev/config.py
296
297
298
def set_tracked_repositories_env(repositories: list[str]) -> None:
    """Set SEEV_TRACK_REPOSITORIES environment variable to a comma-separated list."""
    os.environ["SEEV_TRACK_REPOSITORIES"] = ",".join(repositories)

Markdown Utilities

DateEntryResponse

Bases: TypedDict

Response from reading a date entry.

Source code in seev/markdown_tools.py
49
50
51
52
53
54
55
56
class DateEntryResponse(TypedDict):
    """Response from reading a date entry."""

    exists: bool
    date: str
    heading_line: int | None
    sections: DateEntrySections
    raw_content: str

DateEntrySections

Bases: TypedDict

Parsed sections from a date entry.

Source code in seev/markdown_tools.py
36
37
38
39
40
41
42
43
44
45
46
class DateEntrySections(TypedDict):
    """Parsed sections from a date entry."""

    goals: list[str]
    technical: list[str]
    metrics: list[str]
    decisions: list[str]
    impact: list[str]
    open_items: list[str]
    learnings: list[str]
    weekly_summary: str | None

MarkdownErrorResponse

Bases: TypedDict

Error response from markdown operation.

Source code in seev/markdown_tools.py
30
31
32
33
class MarkdownErrorResponse(TypedDict):
    """Error response from markdown operation."""

    error: str

MarkdownSuccessResponse

Bases: TypedDict

Successful markdown append operation response.

Source code in seev/markdown_tools.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class MarkdownSuccessResponse(TypedDict):
    """Successful markdown append operation response."""

    ok: bool
    path: str
    bullets_added: int
    content_added: list[str]
    line_numbers_added: list[int]
    heading: str
    heading_added: bool
    heading_line_number: int | None
    used_env: bool
    defaulted: bool
    update_mode_used: bool
    existing_bullets_preserved: int
    new_bullets_added: int
    deduplicated_count: int

append_to_markdown(content, file_path=None, date_str=None, *, preserve_lines=False, update_mode=False)

Append content under a date heading in a markdown file.

Behavior: - Default (preserve_lines=False): Each non-empty input line becomes a markdown bullet ("- ..."). - Raw mode (preserve_lines=True): Lines are written as-is (no automatic bullet prefix). Useful for writing headings like "### Goals". - Update mode (update_mode=True): Read existing entry for the date and merge new content with existing sections instead of appending. - Ensures a heading for the specified date ("## YYYY-MM-DD"). If no date is provided, uses the current local date. Creates it if missing. - Appends content under the chosen date's heading, before the next heading or at the end. - Path resolution: file_path argument > SEEV_MD_PATH env var > seev.toml markdown_path (with glin.toml fallback) > ./WORKLOG.md.

Parameters:

Name Type Description Default
content str

The text to append. Must be non-empty (after stripping). Newlines will be normalized.

required
file_path str | None

Optional target file path. Can be absolute or relative to the repo root.

None
date_str str | None

Optional date string in ISO format (YYYY-MM-DD). When provided, content is added under this date's heading instead of today's.

None
preserve_lines bool

When True, write lines as-is; when False, prefix each non-empty line with "- ".

False
update_mode bool

When True, read existing entry for the date and merge new content with existing sections instead of appending. When False (default), append as before (backward compatible).

False

Returns:

Type Description
MarkdownSuccessResponse | MarkdownErrorResponse

A dict with operation details or an error message, including the exact content and

MarkdownSuccessResponse | MarkdownErrorResponse

line numbers added.

Source code in seev/markdown_tools.py
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
def append_to_markdown(
    content: str,
    file_path: str | None = None,
    date_str: str | None = None,
    *,
    preserve_lines: bool = False,
    update_mode: bool = False,
) -> MarkdownSuccessResponse | MarkdownErrorResponse:
    """
    Append content under a date heading in a markdown file.

    Behavior:
    - Default (preserve_lines=False): Each non-empty input line becomes a markdown bullet
      ("- ...").
    - Raw mode (preserve_lines=True): Lines are written as-is (no automatic bullet prefix).
      Useful for writing headings like "### Goals".
    - Update mode (update_mode=True): Read existing entry for the date and merge new content
      with existing sections instead of appending.
    - Ensures a heading for the specified date ("## YYYY-MM-DD"). If no date is provided, uses
      the current local date. Creates it if missing.
    - Appends content under the chosen date's heading, before the next heading or at the end.
    - Path resolution: file_path argument > SEEV_MD_PATH env var > seev.toml `markdown_path`
      (with glin.toml fallback) > ./WORKLOG.md.

    Args:
        content: The text to append. Must be non-empty (after stripping). Newlines will be
            normalized.
        file_path: Optional target file path. Can be absolute or relative to the repo root.
        date_str: Optional date string in ISO format (YYYY-MM-DD). When provided, content is
            added under this date's heading instead of today's.
        preserve_lines: When True, write lines as-is; when False, prefix each non-empty line
            with "- ".
        update_mode: When True, read existing entry for the date and merge new content with
            existing sections instead of appending. When False (default), append as before
            (backward compatible).

    Returns:
        A dict with operation details or an error message, including the exact content and
        line numbers added.
    """
    try:
        if content is None or str(content).strip() == "":
            error_response: MarkdownErrorResponse = {
                "error": "content is required and cannot be empty"
            }
            return error_response

        from datetime import date, datetime

        # Resolve target path: parameter > env var > glin.toml > default
        if file_path and str(file_path).strip():
            target = str(file_path).strip()
            used_env_flag = False
            defaulted_flag = False
        else:
            # Delegate env/TOML/default resolution to config
            target = get_markdown_path()
            used_env_flag = bool(os.getenv("SEEV_MD_PATH"))
            # Consider defaulted when neither param nor env provided and config didn't override
            defaulted_flag = (not used_env_flag) and (target == "WORKLOG.md")
        path = Path(target)
        if not path.is_absolute():
            path = Path.cwd() / path

        # Ensure parent directory exists
        path.parent.mkdir(parents=True, exist_ok=True)

        # Prepare date heading (validate provided date_str if any)
        if date_str is not None:
            try:
                # Validate format using fromisoformat; it raises ValueError for invalid strings
                chosen_date: date = date.fromisoformat(date_str)
            except ValueError:
                return {"error": "date_str must be in YYYY-MM-DD format"}
            date_for_heading = chosen_date.isoformat()
        else:
            date_for_heading = datetime.now().date().isoformat()

        # UPDATE MODE: Read existing entry and merge
        if update_mode:
            existing = read_date_entry(date_for_heading, file_path)

            if existing["exists"]:
                # Merge new content with existing
                merged_content, deduplicated_count = merge_date_sections(
                    existing, content, preserve_lines=preserve_lines
                )

                # Read the file to replace the date section
                if path.exists():
                    file_content = (
                        path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n")
                    )
                    doc_lines = file_content.split("\n")
                    if doc_lines and doc_lines[-1] == "":
                        doc_lines.pop()
                else:
                    doc_lines = []

                # Find the date heading
                heading = f"## {date_for_heading}"
                heading_idx = None
                for i, line in enumerate(doc_lines):
                    if line.strip() == heading:
                        heading_idx = i
                        break

                # Find the end of this date section
                end_idx = len(doc_lines)
                if heading_idx is not None:
                    for i in range(heading_idx + 1, len(doc_lines)):
                        stripped = doc_lines[i].strip()
                        # Match only level 2 headings (## ), not level 3 (###) or deeper
                        if stripped.startswith("## ") and len(stripped) > 3:
                            end_idx = i
                            break

                    # Replace the section content (keep heading, replace content
                    # until the next heading)
                    merged_lines = merged_content.split("\n")
                    # Remove leading empty line if present (we'll add proper spacing)
                    while merged_lines and merged_lines[0].strip() == "":
                        merged_lines.pop(0)

                    # Ensure blank line after heading
                    replacement = [""]
                    replacement.extend(merged_lines)

                    # Ensure blank line before next heading if there is one
                    if end_idx < len(doc_lines):
                        replacement.append("")

                    # Replace content between heading and next section
                    doc_lines[heading_idx + 1 : end_idx] = replacement

                    # Count bullets in merged content
                    bullet_count = sum(1 for line in merged_lines if line.strip().startswith("- "))

                    # Reconstruct and write
                    new_content = "\n".join(doc_lines)
                    if not new_content.endswith("\n"):
                        new_content += "\n"
                    path.write_text(new_content, encoding="utf-8")

                    # Calculate statistics
                    existing_bullet_count = sum(
                        len(bullets)
                        for bullets in existing["sections"].values()
                        if isinstance(bullets, list)
                    )
                    new_bullets_added = bullet_count - existing_bullet_count + deduplicated_count

                    success_response: MarkdownSuccessResponse = {
                        "ok": True,
                        "path": str(path),
                        "bullets_added": bullet_count,
                        "content_added": merged_lines,
                        "line_numbers_added": list(
                            range(heading_idx + 2, heading_idx + 2 + len(merged_lines))
                        ),
                        "heading": heading,
                        "heading_added": False,
                        "heading_line_number": heading_idx + 1,
                        "used_env": used_env_flag,
                        "defaulted": defaulted_flag,
                        "update_mode_used": True,
                        "existing_bullets_preserved": existing_bullet_count,
                        "new_bullets_added": new_bullets_added,
                        "deduplicated_count": deduplicated_count,
                    }
                    return success_response
            # If no existing entry, fall through to normal append logic below

        # Normalize input newlines to Unix and split into lines
        text = str(content).replace("\r\n", "\n").replace("\r", "\n")
        lines = [
            ln.rstrip() for ln in text.split("\n")
        ]  # preserve leading '#' etc.; strip right only
        # Build the block lines according to mode
        if preserve_lines:
            block_lines = [ln for ln in lines if ln.strip() != ""]
        else:
            block_lines = [f"- {ln.strip()}" for ln in lines if ln.strip() != ""]
        if not block_lines:
            error_response: MarkdownErrorResponse = {"error": "content contained only blank lines"}
            return error_response

        heading = f"## {date_for_heading}"

        # Read existing file (normalize to Unix newlines)
        existing = ""
        if path.exists():
            existing = path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n")
        # Ensure file ends with single newline for consistent processing
        if existing and not existing.endswith("\n"):
            existing += "\n"

        # Work with list of lines for insertion
        doc_lines = existing.split("\n") if existing else []
        # Remove a possible trailing empty string from split if file ended with newline
        if doc_lines and doc_lines[-1] == "":
            doc_lines.pop()

        # Find today's heading
        try:
            heading_idx = next(i for i, ln in enumerate(doc_lines) if ln.strip() == heading)
            heading_exists = True
        except StopIteration:
            heading_exists = False
            heading_idx = None

        # If heading is missing, append it at end with proper spacing
        if not heading_exists:
            # Ensure blank line before new heading if file not empty and last line not blank
            if doc_lines and doc_lines[-1].strip() != "":
                doc_lines.append("")
            doc_lines.append(heading)
            doc_lines.append("")  # blank line after heading
            # After appending, recalculate heading index

        # Determine insertion index: after the heading and any existing content of that section,
        # which we define as lines until the next heading (line starting with '#').
        # Recompute heading index to be robust
        heading_idx = next((i for i, ln in enumerate(doc_lines) if ln.strip() == heading), None)
        if heading_idx is None:
            # Fallback: append heading at end if somehow missing
            if doc_lines and doc_lines[-1].strip() != "":
                doc_lines.append("")
            doc_lines.append(heading)
            doc_lines.append("")
            heading_idx = len(doc_lines) - 2  # index of heading line
            heading_exists = False

        # Find next heading after current section
        next_heading_idx = None
        for i in range(heading_idx + 1, len(doc_lines)):
            if doc_lines[i].lstrip().startswith("#") and doc_lines[i].strip() != "":
                next_heading_idx = i
                break

        # Build the new section content to insert
        insert_block = []
        # Ensure there is a blank line after heading if the next line isn't blank
        # and we're inserting directly
        after_heading_idx = heading_idx + 1
        if after_heading_idx >= len(doc_lines) or doc_lines[after_heading_idx].strip() != "":
            insert_block.append("")
        insert_block.extend(block_lines)

        # If there will be another heading after, ensure there is a blank line before it
        if next_heading_idx is not None:
            insert_block.append("")

        # Compute insertion position
        insert_pos = next_heading_idx if next_heading_idx is not None else len(doc_lines)

        # Determine the 1-based line numbers for the bullets we will insert
        # First, compute where within insert_block the bullets start
        bullets_offset_in_block = 1 if (len(insert_block) > 0 and insert_block[0] == "") else 0
        bullet_line_numbers = []
        # The final line number for a given inserted line at block index k is (insert_pos + k) + 1
        for idx in range(len(block_lines)):
            k = bullets_offset_in_block + idx
            bullet_line_numbers.append(insert_pos + k + 1)

        # If we created the heading in this call, compute its 1-based line number
        # in the final document
        heading_added = not heading_exists
        heading_line_number = None
        if heading_added:
            # Heading line is at heading_idx (recomputed above) in doc_lines
            # BEFORE inserting insert_block
            # Since we haven't yet inserted insert_block, its final 1-based line
            # number is heading_idx + 1
            heading_line_number = heading_idx + 1

        # Insert bullets block
        doc_lines[insert_pos:insert_pos] = insert_block

        # Reconstruct content with Unix newlines and ensure file ends with newline
        new_content = "".join(line + "\n" for line in doc_lines)
        if not new_content.endswith("\n"):
            new_content += "\n"

        path.write_text(new_content, encoding="utf-8")

        success_response: MarkdownSuccessResponse = {
            "ok": True,
            "path": str(path),
            "bullets_added": len(block_lines),
            "content_added": block_lines,
            "line_numbers_added": bullet_line_numbers,
            "heading": heading,
            "heading_added": heading_added,
            "heading_line_number": heading_line_number,
            "used_env": used_env_flag,
            "defaulted": defaulted_flag,
            "update_mode_used": False,
            "existing_bullets_preserved": 0,
            "new_bullets_added": len(block_lines),
            "deduplicated_count": 0,
        }
        return success_response
    except Exception as e:
        error_response: MarkdownErrorResponse = {"error": f"Failed to append to markdown: {e}"}
        return error_response

merge_date_sections(existing, new_content, *, preserve_lines=False)

Merge new content with existing date entry sections.

Parameters:

Name Type Description Default
existing DateEntryResponse

Result from read_date_entry

required
new_content str

New markdown content to merge (with ### sections)

required
preserve_lines bool

Same semantics as append_to_markdown

False

Returns:

Type Description
tuple[str, int]

Tuple of (merged markdown content ready to write, deduplicated count)

Source code in seev/markdown_tools.py
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
def merge_date_sections(
    existing: DateEntryResponse,
    new_content: str,
    *,
    preserve_lines: bool = False,
) -> tuple[str, int]:
    """
    Merge new content with existing date entry sections.

    Args:
        existing: Result from read_date_entry
        new_content: New markdown content to merge (with ### sections)
        preserve_lines: Same semantics as append_to_markdown

    Returns:
        Tuple of (merged markdown content ready to write, deduplicated count)
    """
    # Parse new content into sections
    new_sections: DateEntrySections = {
        "goals": [],
        "technical": [],
        "metrics": [],
        "decisions": [],
        "impact": [],
        "open_items": [],
        "learnings": [],
        "weekly_summary": None,
    }

    # Normalize and split new content
    text = str(new_content).replace("\r\n", "\n").replace("\r", "\n")
    lines = [ln.rstrip() for ln in text.split("\n")]

    # Section patterns (same as read_date_entry)
    section_patterns = {
        "goals": r"###\s*🎯\s*Goals?\s*(&\s*Context)?",
        "technical": r"###\s*💻\s*Technical\s*Work",
        "metrics": r"###\s*📊\s*Metrics?",
        "decisions": r"###\s*🔍\s*Key\s*Decisions?",
        "impact": r"###\s*⚠️\s*Impact\s*Assessment",
        "open_items": r"###\s*🚧\s*Open\s*Items?",
        "learnings": r"###\s*📚\s*Learnings?",
        "weekly_summary": r"###\s*🗓️\s*Weekly\s*Summary",
    }

    current_section = None
    weekly_summary_lines = []

    for line in lines:
        # Check if this line starts a new section
        matched_section = None
        for section_key, pattern in section_patterns.items():
            if re.match(pattern, line.strip(), re.IGNORECASE):
                matched_section = section_key
                break

        if matched_section:
            current_section = matched_section
            if current_section == "weekly_summary":
                weekly_summary_lines = []
        elif current_section:
            stripped = line.strip()
            if current_section == "weekly_summary":
                weekly_summary_lines.append(line)
            elif stripped.startswith("- "):
                bullet_content = stripped[2:].strip()
                if bullet_content:
                    new_sections[current_section].append(bullet_content)
            elif stripped.startswith("* "):
                bullet_content = stripped[2:].strip()
                if bullet_content:
                    new_sections[current_section].append(bullet_content)
            elif preserve_lines and stripped:
                # In preserve_lines mode, include non-bullet lines too
                new_sections[current_section].append(stripped)

    if weekly_summary_lines:
        new_sections["weekly_summary"] = "\n".join(weekly_summary_lines).strip()

    # Merge sections with deduplication
    merged_sections: DateEntrySections = {
        "goals": [],
        "technical": [],
        "metrics": [],
        "decisions": [],
        "impact": [],
        "open_items": [],
        "learnings": [],
        "weekly_summary": None,
    }

    total_duplicates = 0

    # Merge each section with appropriate strategy
    for section_key in ["goals", "decisions", "impact", "open_items", "learnings"]:
        merged_sections[section_key], dupes = _deduplicate_bullets(
            existing["sections"][section_key], new_sections[section_key]
        )
        total_duplicates += dupes

    # Technical section: deduplicate by commit hash
    merged_sections["technical"], dupes = _deduplicate_commits(
        existing["sections"]["technical"], new_sections["technical"]
    )
    total_duplicates += dupes

    # Metrics: prefer new (recalculated) over existing
    merged_sections["metrics"] = (
        new_sections["metrics"] if new_sections["metrics"] else existing["sections"]["metrics"]
    )

    # Weekly summary: preserve existing unless new one provided
    if new_sections["weekly_summary"]:
        merged_sections["weekly_summary"] = new_sections["weekly_summary"]
    else:
        merged_sections["weekly_summary"] = existing["sections"]["weekly_summary"]

    # Reconstruct markdown content
    output_lines = []

    # Add sections in standard order
    section_order = [
        ("goals", "### 🎯 Goals & Context"),
        ("technical", "### 💻 Technical Work"),
        ("metrics", "### 📊 Metrics"),
        ("decisions", "### 🔍 Key Decisions"),
        ("impact", "### ⚠️ Impact Assessment"),
        ("open_items", "### 🚧 Open Items"),
        ("learnings", "### 📚 Learnings"),
    ]

    for section_key, section_heading in section_order:
        bullets = merged_sections[section_key]
        if bullets:
            output_lines.append("")
            output_lines.append(section_heading)
            output_lines.append("")
            for bullet in bullets:
                output_lines.append(f"- {bullet}")

    # Add weekly summary if present
    if merged_sections["weekly_summary"]:
        output_lines.append("")
        output_lines.append("### 🗓️ Weekly Summary")
        output_lines.append("")
        # Weekly summary content already formatted
        for line in merged_sections["weekly_summary"].split("\n"):
            output_lines.append(line)

    # Join with newlines
    merged_content = "\n".join(output_lines)

    return merged_content, total_duplicates

read_date_entry(date_str, file_path=None)

Read and parse an existing date entry from the markdown file.

Parameters:

Name Type Description Default
date_str str

Date in ISO format (YYYY-MM-DD) to search for.

required
file_path str | None

Optional target file path. Uses same resolution as append_to_markdown.

None

Returns:

Type Description
DateEntryResponse

A dict with exists flag, date, heading line number, parsed sections, and raw content.

DateEntryResponse

If the date entry doesn't exist, returns exists=False with empty sections.

Source code in seev/markdown_tools.py
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def read_date_entry(
    date_str: str,
    file_path: str | None = None,
) -> DateEntryResponse:
    """
    Read and parse an existing date entry from the markdown file.

    Args:
        date_str: Date in ISO format (YYYY-MM-DD) to search for.
        file_path: Optional target file path. Uses same resolution as append_to_markdown.

    Returns:
        A dict with exists flag, date, heading line number, parsed sections, and raw content.
        If the date entry doesn't exist, returns exists=False with empty sections.
    """
    from datetime import date

    # Validate date format
    try:
        parsed_date = date.fromisoformat(date_str)
        date_iso = parsed_date.isoformat()
    except ValueError:
        # Return non-existent entry for invalid date
        return {
            "exists": False,
            "date": date_str,
            "heading_line": None,
            "sections": {
                "goals": [],
                "technical": [],
                "metrics": [],
                "decisions": [],
                "impact": [],
                "open_items": [],
                "learnings": [],
                "weekly_summary": None,
            },
            "raw_content": "",
        }

    # Resolve target path using same logic as append_to_markdown
    if file_path and str(file_path).strip():
        target = str(file_path).strip()
    else:
        target = get_markdown_path()
    path = Path(target)
    if not path.is_absolute():
        path = Path.cwd() / path

    # If file doesn't exist, return non-existent entry
    if not path.exists():
        return {
            "exists": False,
            "date": date_iso,
            "heading_line": None,
            "sections": {
                "goals": [],
                "technical": [],
                "metrics": [],
                "decisions": [],
                "impact": [],
                "open_items": [],
                "learnings": [],
                "weekly_summary": None,
            },
            "raw_content": "",
        }

    # Read and normalize file content
    content = path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n")
    lines = content.split("\n")

    # Find the date heading
    heading = f"## {date_iso}"
    heading_idx = None
    for i, line in enumerate(lines):
        if line.strip() == heading:
            heading_idx = i
            break

    # If heading not found, return non-existent entry
    if heading_idx is None:
        return {
            "exists": False,
            "date": date_iso,
            "heading_line": None,
            "sections": {
                "goals": [],
                "technical": [],
                "metrics": [],
                "decisions": [],
                "impact": [],
                "open_items": [],
                "learnings": [],
                "weekly_summary": None,
            },
            "raw_content": "",
        }

    # Find the end of this date section (next ## heading or end of file)
    end_idx = len(lines)
    for i in range(heading_idx + 1, len(lines)):
        stripped = lines[i].strip()
        # Match only level 2 headings (## ), not level 3 (###) or deeper
        if stripped.startswith("## ") and len(stripped) > 3:
            end_idx = i
            break

    # Extract raw content for this date section
    section_lines = lines[heading_idx + 1 : end_idx]
    raw_content = "\n".join(section_lines)

    # Parse sections by ### headings with emoji markers
    sections: DateEntrySections = {
        "goals": [],
        "technical": [],
        "metrics": [],
        "decisions": [],
        "impact": [],
        "open_items": [],
        "learnings": [],
        "weekly_summary": None,
    }

    # Section markers (emoji + text patterns)
    section_patterns = {
        "goals": r"###\s*🎯\s*Goals?\s*(&\s*Context)?",
        "technical": r"###\s*💻\s*Technical\s*Work",
        "metrics": r"###\s*📊\s*Metrics?",
        "decisions": r"###\s*🔍\s*Key\s*Decisions?",
        "impact": r"###\s*⚠️\s*Impact\s*Assessment",
        "open_items": r"###\s*🚧\s*Open\s*Items?",
        "learnings": r"###\s*📚\s*Learnings?",
        "weekly_summary": r"###\s*🗓️\s*Weekly\s*Summary",
    }

    current_section = None
    weekly_summary_lines = []

    for line in section_lines:
        # Check if this line starts a new section
        matched_section = None
        for section_key, pattern in section_patterns.items():
            if re.match(pattern, line.strip(), re.IGNORECASE):
                matched_section = section_key
                break

        if matched_section:
            current_section = matched_section
            if current_section == "weekly_summary":
                weekly_summary_lines = []
        elif current_section:
            # Extract content based on section type
            stripped = line.strip()
            if current_section == "weekly_summary":
                # Collect all lines for weekly summary
                weekly_summary_lines.append(line)
            elif stripped.startswith("- "):
                # Extract bullet content (remove "- " prefix)
                bullet_content = stripped[2:].strip()
                if bullet_content:
                    sections[current_section].append(bullet_content)
            elif stripped.startswith("* "):
                # Also handle * bullets
                bullet_content = stripped[2:].strip()
                if bullet_content:
                    sections[current_section].append(bullet_content)

    # Set weekly summary if any content was collected
    if weekly_summary_lines:
        sections["weekly_summary"] = "\n".join(weekly_summary_lines).strip()

    return {
        "exists": True,
        "date": date_iso,
        "heading_line": heading_idx + 1,  # 1-based line number
        "sections": sections,
        "raw_content": raw_content,
    }

Git Tools

Branches

Commits

Files

Diffs

Remotes

determine_commit_url_prefix(remote_url)

Given a Git remote URL, derive the HTTPS commit URL prefix for common hosts.

Supported providers and their commit URL patterns: - GitHub: https://///commit/ - GitLab: https://///-/commit/ - Bitbucket: https://///commits/

Returns {"prefix": "..."} or {"error": "..."} when unknown or unparseable.

Source code in seev/git_tools/remotes.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def determine_commit_url_prefix(remote_url: str | None) -> CommitUrlPrefixResult:
    """
    Given a Git remote URL, derive the HTTPS commit URL prefix for common hosts.

    Supported providers and their commit URL patterns:
    - GitHub:    https://<host>/<owner>/<repo>/commit/
    - GitLab:    https://<host>/<owner>/<repo>/-/commit/
    - Bitbucket: https://<host>/<owner>/<repo>/commits/

    Returns {"prefix": "..."} or {"error": "..."} when unknown or unparseable.
    """
    if not remote_url:
        return {"error": "remote_url is required"}

    url = remote_url.strip()
    host: str | None = None
    path: str | None = None

    try:
        # SSH formats
        # - git@host:owner/repo.git
        # - ssh://git@host/owner/repo.git
        if url.startswith("ssh://"):
            rest = url.split("://", 1)[1]
            # Remove optional user@
            if "@" in rest:
                rest = rest.split("@", 1)[1]
            if "/" in rest:
                host, path = rest.split("/", 1)
        elif "@" in url and ":" in url and url.split("@", 1)[0].isidentifier():
            # Likely git@host:owner/repo.git (simple heuristic)
            user_host, path = url.split(":", 1)
            host = user_host.split("@", 1)[1]
        # HTTPS/HTTP format: https://host/owner/repo(.git)
        elif url.startswith("http://") or url.startswith("https://"):
            rest = url.split("://", 1)[1]
            if "/" in rest:
                host, path = rest.split("/", 1)
        else:
            return {"error": "Unrecognized remote URL format"}

        if not host or not path:
            return {"error": "Remote URL missing host or path"}

        # Normalize path: drop leading slashes and trailing .git
        path = path.lstrip("/")
        if path.endswith(".git"):
            path = path[:-4]

        # In practice, we expect owner/repo; if more segments exist, keep them as-is.
        base = f"https://{host}/{path}"
        host_l = host.lower()
        if host_l == "github.com" or "github." in host_l:
            return {"prefix": base + "/commit/"}
        if host_l == "gitlab.com" or "gitlab." in host_l:
            return {"prefix": base + "/-/commit/"}
        if host_l == "bitbucket.org" or "bitbucket." in host_l:
            return {"prefix": base + "/commits/"}
        return {"error": "Unknown host for commit URL prefix"}
    except Exception as e:  # pragma: no cover - be defensive in tool
        return {"error": f"Failed to parse remote URL: {str(e)}"}

get_remote_origin(workdir=None)

Return information about the remote named 'origin' for the current repository.

On success: {"name": "origin", "url": "..."} On failure: {"error": "..."}

Source code in seev/git_tools/remotes.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def get_remote_origin(workdir: str | None = None) -> RemoteInfo:
    """
    Return information about the remote named 'origin' for the current repository.

    On success: {"name": "origin", "url": "..."}
    On failure: {"error": "..."}
    """
    try:
        # Resolve repo root only when an explicit workdir is provided to preserve default behavior
        repo_root: str | None = None
        if workdir is not None:
            root_res = resolve_repo_root(workdir)
            if "error" in root_res:
                return {"error": root_res["error"]}
            repo_root = root_res.get("path")

        if repo_root:
            res = run_git(["remote", "get-url", "origin"], repo_root=repo_root)
        else:
            res = subprocess.run(
                ["git", "remote", "get-url", "origin"], capture_output=True, text=True, check=True
            )
        url = (res.stdout or "").strip()
        if not url:
            return {"error": "Remote 'origin' has no URL configured"}
        return {"name": "origin", "url": url}
    except subprocess.CalledProcessError as e:  # noqa: BLE001
        # Git returns non-zero if origin does not exist or repo is not a git repo
        err = (e.stderr or "").strip() or (e.stdout or "").strip()
        if not err:
            err = "Git command failed"
        return {"error": err}
    except Exception as e:  # noqa: BLE001
        return {"error": f"Failed to get remote origin: {str(e)}"}

Analysis

blame_file(path, start_line=1, end_line=None, rev='HEAD', workdir=None)

Run git blame on a file or a line range.

Returns a structured list with commit, author, date and code for each line.

Source code in seev/git_tools/analysis.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
def blame_file(
    path: str,
    start_line: int = 1,
    end_line: int | None = None,
    rev: str = "HEAD",
    workdir: str | None = None,
) -> dict:
    """Run git blame on a file or a line range.

    Returns a structured list with commit, author, date and code for each line.
    """
    try:
        repo_root: str | None = None
        if workdir is not None:
            root_res = resolve_repo_root(workdir)
            if "error" in root_res:
                return _err(root_res["error"])
            repo_root = root_res.get("path")

        args = ["blame", rev, "--line-porcelain"]
        if end_line is not None:
            args += [f"-L{start_line},{end_line}"]
        elif start_line != 1:
            args += [f"-L{start_line},+999999"]
        args.append(path)
        res = run_git(args, repo_root=repo_root)
        lines = res.stdout.splitlines()

        entries: list[dict] = []
        cur: dict | None = None
        for ln in lines:
            if re.match(r"^[0-9a-f]{7,40} ", ln):
                # start of a block
                if cur:
                    entries.append(cur)
                parts = ln.split()
                cur = {
                    "commit": parts[0],
                    "orig_line": int(parts[2]) if len(parts) > 2 else None,
                    "final_line": int(parts[1]) if len(parts) > 1 else None,
                    "author": None,
                    "author_mail": None,
                    "author_time": None,
                    "summary": None,
                    "code": None,
                }
            elif ln.startswith("author ") and cur is not None:
                cur["author"] = ln[len("author ") :]
            elif ln.startswith("author-mail ") and cur is not None:
                cur["author_mail"] = ln[len("author-mail ") :].strip("<>")
            elif ln.startswith("author-time ") and cur is not None:
                cur["author_time"] = int(ln[len("author-time ") :])
            elif ln.startswith("summary ") and cur is not None:
                cur["summary"] = ln[len("summary ") :]
            elif ln.startswith("\t") and cur is not None:
                cur["code"] = ln[1:]
        if cur:
            entries.append(cur)
        return {"path": path, "rev": rev, "start": start_line, "end": end_line, "entries": entries}
    except subprocess.CalledProcessError as e:  # noqa: BLE001
        return _err(f"Git command failed: {e.stderr}")
    except Exception as e:  # noqa: BLE001
        return _err(f"Failed to run git blame: {str(e)}")

categorize_commit(message_or_hash, is_hash=False, workdir=None)

Categorize a commit message using Conventional Commits.

If is_hash=True, the argument is treated as a commit hash and its subject is resolved first.

Source code in seev/git_tools/analysis.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
def categorize_commit(
    message_or_hash: str, is_hash: bool = False, workdir: str | None = None
) -> Categorization | ErrorResponse:
    """Categorize a commit message using Conventional Commits.

    If is_hash=True, the argument is treated as a commit hash and its subject is resolved first.
    """
    try:
        raw = _get_commit_message(message_or_hash, workdir=workdir) if is_hash else message_or_hash
        m = _CONVENTIONAL_RE.match(raw)
        if not m:
            return {
                "type": "other",
                "scope": None,
                "description": raw,
                "conventional": False,
                "raw": raw,
                **({"hash": message_or_hash} if is_hash else {}),
            }
        ctype_raw = m.group("type")
        ctype = ctype_raw.lower()
        scope = m.group("scope")
        desc = m.group("desc")
        conventional = ctype in _CONVENTIONAL_TYPES
        return {
            "type": ctype,
            "scope": scope,
            "description": desc,
            "conventional": conventional,
            "raw": raw,
            **({"hash": message_or_hash} if is_hash else {}),
        }
    except subprocess.CalledProcessError as e:  # noqa: BLE001
        return _err(f"Git command failed: {e.stderr}")
    except Exception as e:  # noqa: BLE001
        return _err(f"Failed to categorize commit: {str(e)}")

detect_merge_info(commit_hash, workdir=None)

Detect whether a commit is a merge, and whether it's a PR merge.

Heuristics for PR merges: - Subject contains "Merge pull request #" (GitHub) - Subject starts with "Merge branch" (generic) → not necessarily a PR - Two or more parents in git rev-list --parents -n 1

Source code in seev/git_tools/analysis.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def detect_merge_info(commit_hash: str, workdir: str | None = None) -> MergeInfo | ErrorResponse:
    """Detect whether a commit is a merge, and whether it's a PR merge.

    Heuristics for PR merges:
    - Subject contains "Merge pull request #<n>" (GitHub)
    - Subject starts with "Merge branch" (generic) → not necessarily a PR
    - Two or more parents in `git rev-list --parents -n 1`
    """
    try:
        repo_root: str | None = None
        if workdir is not None:
            root_res = resolve_repo_root(workdir)
            if "error" in root_res:
                return _err(root_res["error"])
            repo_root = root_res.get("path")

        parents_res = run_git(
            ["rev-list", "--parents", "-n", "1", commit_hash], repo_root=repo_root
        )
        parts = parents_res.stdout.strip().split()
        if not parts:
            return _err(f"Commit {commit_hash} not found")
        _hash, *parents = parts
        message = _get_commit_message(commit_hash, workdir=workdir)

        is_merge = len(parents) >= 2
        pr_number: int | None = None
        is_pr_merge = False

        m = re.search(r"Merge pull request #(\d+)", message, flags=re.IGNORECASE)
        if m:
            pr_number = int(m.group(1))
            is_pr_merge = True
        elif is_merge and message.lower().startswith("merge branch"):
            is_pr_merge = False

        return {
            "hash": commit_hash,
            "parents": parents,
            "is_merge": is_merge,
            "is_pr_merge": is_pr_merge,
            "pr_number": pr_number,
            "message": message,
        }
    except subprocess.CalledProcessError as e:  # noqa: BLE001
        return _err(f"Git command failed: {e.stderr}")
    except Exception as e:  # noqa: BLE001
        return _err(f"Failed to detect merge info: {str(e)}")

get_commit_statistics(commit_hash, workdir=None)

Aggregate simple statistics for a commit, including language breakdown.

Uses git show --numstat and infers language from file extensions.

Source code in seev/git_tools/analysis.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
def get_commit_statistics(
    commit_hash: str, workdir: str | None = None
) -> CommitStats | ErrorResponse:
    """Aggregate simple statistics for a commit, including language breakdown.

    Uses `git show --numstat` and infers language from file extensions.
    """
    try:
        repo_root: str | None = None
        if workdir is not None:
            root_res = resolve_repo_root(workdir)
            if "error" in root_res:
                return _err(root_res["error"])
            repo_root = root_res.get("path")

        numstat = run_git(
            ["show", "--numstat", "--pretty=format:", commit_hash], repo_root=repo_root
        )
        additions = 0
        deletions = 0
        files_changed = 0
        lang_data: dict[str, dict[str, int]] = defaultdict(
            lambda: {"additions": 0, "deletions": 0, "files": 0}
        )

        for line in numstat.stdout.strip().split("\n"):
            if not line:
                continue
            parts = line.split("\t")
            if len(parts) < 3:
                continue
            add_s, del_s, path = parts[0], parts[1], parts[2]
            add = 0 if add_s == "-" else int(add_s)
            delete = 0 if del_s == "-" else int(del_s)
            additions += add
            deletions += delete
            files_changed += 1

            lang = _infer_language(path)
            d = lang_data[lang]
            d["additions"] += add
            d["deletions"] += delete
            d["files"] += 1

        return {
            "hash": commit_hash,
            "additions": additions,
            "deletions": deletions,
            "files_changed": files_changed,
            "by_language": dict(lang_data),
        }
    except subprocess.CalledProcessError as e:  # noqa: BLE001
        return _err(f"Git command failed: {e.stderr}")
    except Exception as e:  # noqa: BLE001
        return _err(f"Failed to compute commit statistics: {str(e)}")

Enrichment

get_enriched_commits(workdir, since, until='now')

Return commits within a date range enriched with analysis metadata.

Adds per-commit statistics, conventional commit categorization, and merge/PR detection.

When the underlying commit query returns informational or error items (e.g., no configured emails, or no commits in range), forward that response directly for the caller to handle. Otherwise, return a structured object with commits and aggregated totals.

Source code in seev/git_tools/enrichment.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def get_enriched_commits(
    workdir: str, since: str, until: str = "now"
) -> EnrichedResult | list[dict]:
    """Return commits within a date range enriched with analysis metadata.

    Adds per-commit statistics, conventional commit categorization, and merge/PR detection.

    When the underlying commit query returns informational or error items (e.g.,
    no configured emails, or no commits in range), forward that response directly
    for the caller to handle. Otherwise, return a structured object with commits
    and aggregated totals.
    """
    commits = get_commits_by_date(workdir, since, until)

    # Forward-through behavior for non-standard responses
    if not isinstance(commits, list) or not commits:
        return commits  # type: ignore[return-value]

    if commits and isinstance(commits[0], dict) and ("error" in commits[0] or "info" in commits[0]):
        return commits  # type: ignore[return-value]

    enriched: list[EnrichedCommit] = []

    total_adds = 0
    total_dels = 0
    total_files = 0

    for c in commits:
        # Skip non-commit dicts defensively
        if "hash" not in c:
            continue

        sha = c["hash"]
        stats = get_commit_statistics(sha, workdir=workdir)
        category = categorize_commit(sha, is_hash=True, workdir=workdir)
        merge_info = detect_merge_info(sha, workdir=workdir)

        # Aggregate totals if stats have expected shape
        try:
            total_adds += int(stats.get("additions", 0))  # type: ignore[arg-type]
            total_dels += int(stats.get("deletions", 0))  # type: ignore[arg-type]
            total_files += int(stats.get("files_changed", 0))  # type: ignore[arg-type]
        except Exception:
            # Keep going even if stats are partially missing
            pass

        enriched.append(
            EnrichedCommit(
                **c,  # type: ignore[arg-type]
                statistics=stats,  # type: ignore[arg-type]
                category=category,  # type: ignore[arg-type]
                merge_info=merge_info,  # type: ignore[arg-type]
            )
        )

    return EnrichedResult(
        commits=enriched,
        totals=EnrichmentTotals(
            additions=total_adds,
            deletions=total_dels,
            files_changed=total_files,
        ),
        generated_at=datetime.now().isoformat(),
    )

Sessions

detect_work_sessions(commits, gap_threshold_minutes=30)

Group commits into logical work sessions.

Heuristics: - Time gaps greater than gap_threshold_minutes start a new session - Primary type inferred from enriched category.type when present - Simple theme inferred from most common non-trivial words in messages

The function is tolerant to mixed commit dict shapes. If commits are not enriched, it still works using basic fields (date, message, etc.).

Source code in seev/git_tools/sessions.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def detect_work_sessions(commits: list[dict], gap_threshold_minutes: int = 30) -> list[WorkSession]:
    """Group commits into logical work sessions.

    Heuristics:
    - Time gaps greater than ``gap_threshold_minutes`` start a new session
    - Primary type inferred from enriched ``category.type`` when present
    - Simple theme inferred from most common non-trivial words in messages

    The function is tolerant to mixed commit dict shapes. If commits are not
    enriched, it still works using basic fields (``date``, ``message``, etc.).
    """
    if not commits:
        return []

    # Sort by date (ISO-like string accepted). Tests ensure date is present.
    sorted_commits = sorted(commits, key=lambda c: c.get("date", ""))

    sessions: list[WorkSession] = []
    current: dict[str, Any] = {"commits": [], "start_time": None, "end_time": None}

    for c in sorted_commits:
        date_str = str(c.get("date", ""))
        if not date_str:
            # Skip items that don't look like commits
            continue
        # Normalize: allow "YYYY-MM-DD HH:MM:SS" or isoformat already
        ts = datetime.fromisoformat(date_str.replace(" ", "T"))

        if current["start_time"] is None:
            current["start_time"] = ts
            current["end_time"] = ts
            current["commits"].append(c)
            continue

        gap_min = (ts - current["end_time"]).total_seconds() / 60.0
        if gap_min > gap_threshold_minutes:
            sessions.append(_finalize_session(current))
            current = {"commits": [c], "start_time": ts, "end_time": ts}
        else:
            current["commits"].append(c)
            current["end_time"] = ts

    if current["commits"]:
        sessions.append(_finalize_session(current))

    return sessions

get_work_sessions(since, until='now', gap_threshold_minutes=30)

High-level API to compute sessions for a date range.

Prefers enriched commits for better type detection; falls back to raw commits when enrichment returns an error/info payload.

Source code in seev/git_tools/sessions.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def get_work_sessions(
    since: str, until: str = "now", gap_threshold_minutes: int = 30
) -> WorkSessionsResult | list[dict]:
    """High-level API to compute sessions for a date range.

    Prefers enriched commits for better type detection; falls back to raw commits
    when enrichment returns an error/info payload.
    """
    data: EnrichedResult | list[dict] = get_enriched_commits(since, until)

    commits: list[dict]
    if isinstance(data, list):
        # error/info passthrough; also handle empty
        return data
    else:
        commits = data.get("commits", [])
        if not commits:
            # try fallback to raw commits for robustness
            raw = get_commits_by_date(since, until)
            if isinstance(raw, list) and raw and not ("error" in raw[0] or "info" in raw[0]):
                commits = raw  # type: ignore[assignment]
            else:
                return raw  # type: ignore[return-value]

    sessions = detect_work_sessions(commits, gap_threshold_minutes=gap_threshold_minutes)

    return WorkSessionsResult(
        sessions=sessions,
        commit_count=len([c for c in commits if isinstance(c, dict) and c.get("hash")]),
        generated_at=datetime.now().isoformat(),
    )

Config Tools