A monorepo management tool for the agentic ages

Add README.md generation to status command

The status command now automatically generates and commits a README.md
file in the main worktree with workspace status in markdown format:
- Summary table with project/package/repo counts
- Projects table with merge counts and status indicators
- Opam packages table with patch counts and merge info
- Git repositories table
- Active worktrees section

Added --no-readme flag to skip README generation.
Added str library dependency for content comparison.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+197 -3
+1 -1
bin/dune
··· 2 2 (name main) 3 3 (public_name unpac) 4 4 (package unpac) 5 - (libraries unpac unpac_opam cmdliner eio_main logs logs.fmt fmt.tty jsont jsont.bytesrw)) 5 + (libraries unpac unpac_opam cmdliner eio_main logs logs.fmt fmt.tty jsont jsont.bytesrw str))
+196 -2
bin/main.ml
··· 1444 1444 `I ("Opam packages", "Vendored packages, patch counts, and merge status"); 1445 1445 `I ("Git repos", "Vendored git repositories and their status"); 1446 1446 `I ("Worktrees", "Any active worktrees with uncommitted changes"); 1447 + `P "Also updates README.md in the main branch with status in markdown format."; 1447 1448 `S Manpage.s_examples; 1448 1449 `Pre " unpac status # Full status 1449 1450 unpac status --short # Compact summary"; ··· 1452 1453 let doc = "Show compact summary only." in 1453 1454 Arg.(value & flag & info ["s"; "short"] ~doc) 1454 1455 in 1455 - let run () short = 1456 + let no_readme_flag = 1457 + let doc = "Don't update README.md." in 1458 + Arg.(value & flag & info ["no-readme"] ~doc) 1459 + in 1460 + let run () short no_readme = 1456 1461 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 1457 1462 let git = Unpac.Worktree.git_dir root in 1458 1463 let main_wt = Unpac.Worktree.path root Unpac.Worktree.Main in ··· 1651 1656 1652 1657 (* Legend *) 1653 1658 Format.printf "Legend: * = worktree active@." 1659 + end; 1660 + 1661 + (* Generate README.md unless --no-readme *) 1662 + if not no_readme then begin 1663 + let buf = Buffer.create 4096 in 1664 + let add = Buffer.add_string buf in 1665 + let addf fmt = Printf.ksprintf add fmt in 1666 + let timestamp = 1667 + let tm = Unix.localtime (Unix.time ()) in 1668 + Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d" 1669 + (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday 1670 + tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec 1671 + in 1672 + 1673 + add "# Unpac Workspace Status\n\n"; 1674 + addf "_Last updated: %s_\n\n" timestamp; 1675 + 1676 + (* Summary *) 1677 + add "## Summary\n\n"; 1678 + addf "| Category | Count |\n"; 1679 + addf "|----------|-------|\n"; 1680 + addf "| Projects | %d |\n" (List.length project_names); 1681 + addf "| Opam Packages | %d |\n" (List.length opam_packages); 1682 + addf "| Git Repositories | %d |\n\n" (List.length git_repos); 1683 + 1684 + (* Projects section *) 1685 + add "## Projects\n\n"; 1686 + if project_names = [] then 1687 + add "_No projects created yet._\n\n" 1688 + else begin 1689 + add "| Project | Opam Merged | Git Merged | Status |\n"; 1690 + add "|---------|-------------|------------|--------|\n"; 1691 + List.iter (fun proj -> 1692 + let proj_branch = "project/" ^ proj in 1693 + let proj_wt = Unpac.Worktree.path root (Unpac.Worktree.Project proj) in 1694 + let wt_exists = Sys.file_exists (snd proj_wt) in 1695 + let dirty = wt_exists && has_changes proj_wt in 1696 + 1697 + let merged_opam = List.filter (fun pkg -> 1698 + is_ancestor (Unpac_opam.Opam.patches_branch pkg) proj_branch 1699 + ) opam_packages in 1700 + let merged_git = List.filter (fun repo -> 1701 + is_ancestor (Unpac.Git_backend.patches_branch repo) proj_branch 1702 + ) git_repos in 1703 + 1704 + let status = 1705 + if dirty then "⚠️ uncommitted" 1706 + else if wt_exists then "📂 worktree active" 1707 + else "✓" in 1708 + addf "| %s | %d | %d | %s |\n" 1709 + proj (List.length merged_opam) (List.length merged_git) status 1710 + ) project_names; 1711 + add "\n" 1712 + end; 1713 + 1714 + (* Opam packages section *) 1715 + add "## Opam Packages\n\n"; 1716 + if opam_packages = [] then 1717 + add "_No opam packages vendored yet._\n\n" 1718 + else begin 1719 + add "| Package | Patches | Merged Into | Status |\n"; 1720 + add "|---------|---------|-------------|--------|\n"; 1721 + List.iter (fun pkg -> 1722 + let vendor_branch = Unpac_opam.Opam.vendor_branch pkg in 1723 + let patches_branch = Unpac_opam.Opam.patches_branch pkg in 1724 + let patch_count = commit_count vendor_branch patches_branch in 1725 + 1726 + let patches_wt = Unpac.Worktree.path root (Unpac.Worktree.Opam_patches pkg) in 1727 + let has_wt = Sys.file_exists (snd patches_wt) in 1728 + let dirty = has_wt && has_changes patches_wt in 1729 + 1730 + let merged_into = List.filter (fun proj -> 1731 + is_ancestor patches_branch ("project/" ^ proj) 1732 + ) project_names in 1733 + 1734 + let merged_str = if merged_into = [] then "-" 1735 + else String.concat ", " merged_into in 1736 + 1737 + let status = 1738 + if dirty then "⚠️ uncommitted" 1739 + else if has_wt then "📂 editing" 1740 + else "✓" in 1741 + 1742 + addf "| %s | %d | %s | %s |\n" pkg patch_count merged_str status 1743 + ) opam_packages; 1744 + add "\n" 1745 + end; 1746 + 1747 + (* Git repositories section *) 1748 + add "## Git Repositories\n\n"; 1749 + if git_repos = [] then 1750 + add "_No git repositories vendored yet._\n\n" 1751 + else begin 1752 + add "| Repository | Patches | Merged Into | Status |\n"; 1753 + add "|------------|---------|-------------|--------|\n"; 1754 + List.iter (fun repo -> 1755 + let vendor_branch = Unpac.Git_backend.vendor_branch repo in 1756 + let patches_branch = Unpac.Git_backend.patches_branch repo in 1757 + let patch_count = commit_count vendor_branch patches_branch in 1758 + 1759 + let patches_wt = Unpac.Worktree.path root (Unpac.Worktree.Git_patches repo) in 1760 + let has_wt = Sys.file_exists (snd patches_wt) in 1761 + let dirty = has_wt && has_changes patches_wt in 1762 + 1763 + let merged_into = List.filter (fun proj -> 1764 + is_ancestor patches_branch ("project/" ^ proj) 1765 + ) project_names in 1766 + 1767 + let merged_str = if merged_into = [] then "-" 1768 + else String.concat ", " merged_into in 1769 + 1770 + let status = 1771 + if dirty then "⚠️ uncommitted" 1772 + else if has_wt then "📂 editing" 1773 + else "✓" in 1774 + 1775 + addf "| %s | %d | %s | %s |\n" repo patch_count merged_str status 1776 + ) git_repos; 1777 + add "\n" 1778 + end; 1779 + 1780 + (* Active worktrees *) 1781 + let active_wts = ref [] in 1782 + List.iter (fun pkg -> 1783 + let wt = Unpac.Worktree.path root (Unpac.Worktree.Opam_patches pkg) in 1784 + if Sys.file_exists (snd wt) then 1785 + active_wts := (Printf.sprintf "vendor/opam/%s-patches" pkg, has_changes wt) :: !active_wts 1786 + ) opam_packages; 1787 + List.iter (fun repo -> 1788 + let wt = Unpac.Worktree.path root (Unpac.Worktree.Git_patches repo) in 1789 + if Sys.file_exists (snd wt) then 1790 + active_wts := (Printf.sprintf "vendor/git/%s-patches" repo, has_changes wt) :: !active_wts 1791 + ) git_repos; 1792 + 1793 + if !active_wts <> [] then begin 1794 + add "## Active Worktrees\n\n"; 1795 + add "| Path | Status |\n"; 1796 + add "|------|--------|\n"; 1797 + List.iter (fun (name, dirty) -> 1798 + let status = if dirty then "⚠️ uncommitted changes" else "✓ clean" in 1799 + addf "| `%s` | %s |\n" name status 1800 + ) (List.rev !active_wts); 1801 + add "\n" 1802 + end; 1803 + 1804 + (* Footer *) 1805 + add "---\n\n"; 1806 + add "_Generated by `unpac status`_\n"; 1807 + 1808 + (* Write README.md *) 1809 + let readme_path = Filename.concat (snd main_wt) "README.md" in 1810 + let content = Buffer.contents buf in 1811 + 1812 + (* Check if content changed *) 1813 + let old_content = 1814 + if Sys.file_exists readme_path then begin 1815 + let ic = open_in readme_path in 1816 + let len = in_channel_length ic in 1817 + let s = really_input_string ic len in 1818 + close_in ic; 1819 + Some s 1820 + end else None 1821 + in 1822 + 1823 + (* Only write and commit if changed (ignoring timestamp line) *) 1824 + let content_without_timestamp s = 1825 + (* Remove the timestamp line for comparison *) 1826 + Str.global_replace (Str.regexp "_Last updated:.*_") "" s 1827 + in 1828 + let changed = match old_content with 1829 + | None -> true 1830 + | Some old -> content_without_timestamp old <> content_without_timestamp content 1831 + in 1832 + 1833 + if changed then begin 1834 + let oc = open_out readme_path in 1835 + output_string oc content; 1836 + close_out oc; 1837 + Format.printf "@.README.md updated.@."; 1838 + (* Git add and commit *) 1839 + Unpac.Git.run_exn ~proc_mgr ~cwd:main_wt ["add"; "README.md"] |> ignore; 1840 + (try 1841 + Unpac.Git.run_exn ~proc_mgr ~cwd:main_wt 1842 + ["commit"; "-m"; "Update workspace status in README.md"] |> ignore; 1843 + Format.printf "Committed README.md changes.@." 1844 + with _ -> 1845 + (* Commit might fail if nothing staged (e.g., only timestamp changed) *) 1846 + ()) 1847 + end 1654 1848 end 1655 1849 in 1656 1850 let info = Cmd.info "status" ~doc ~man in 1657 - Cmd.v info Term.(const run $ logging_term $ short_flag) 1851 + Cmd.v info Term.(const run $ logging_term $ short_flag $ no_readme_flag) 1658 1852 1659 1853 (* Main command *) 1660 1854 let main_cmd =