The CLI coordinates work: g1t session start, heartbeat, log, attach, end, list and view record a harness's run, with the current session kept per working copy in the worktree's own git directory (g1t/session) and G1T_SESSION overriding it; g1t work context, claim, release, handoff, list, block and unblock read an issue's work and say who is on it, a claim carrying the current session; g1t work events lists the repository's events and with --follow keeps printing them, one line or one JSON object each, by holding list_events with after and wait, resuming from the last event and backing off on failures; g1t why PATH[:LINE] says which commit, pull request, issue, decisions and sessions explain a line; and g1t status lists your active sessions and claims in the current repository. These commands never prompt, fail at once with exit code 4 without a token, give up on a call after 15 seconds, and say when the server is older than the CLI. Operations the API's document does not describe yet are listed as pending in ops.rs until it does. The CLI guide and the generated command reference cover them.
14 files+2369−110/14 viewed
| 281 | 281 | ||
| 282 | 282 | Your open pull requests and their checks, reviews asked of you, what is | |
| 283 | 283 | assigned to you, and failing checks, across every repository you can see. | |
| 284 | + | Inside a working copy it also lists your active sessions and claims in that | |
| 285 | + | repository. | |
| 286 | + | ||
| 287 | + | ## Coordinating work | |
| 288 | + | ||
| 289 | + | When several people and agents work in one repository, `g1t session` and | |
| 290 | + | `g1t work` say who is on what, so nobody starts work that is already under | |
| 291 | + | way and everyone hears what changes under them. Claims are advisory: a | |
| 292 | + | claim never stops anyone, it tells them. | |
| 293 | + | ||
| 294 | + | ### Sessions | |
| 295 | + | ||
| 296 | + | A session is one run of an agent's harness, such as one Claude Code | |
| 297 | + | session. Starting one remembers it for the working copy, and the commands | |
| 298 | + | after it use it: | |
| 299 | + | ||
| 300 | + | ```sh | |
| 301 | + | g1t session start --harness claude-code --issue 12 | |
| 302 | + | g1t session log "Split the parser before touching the lexer." | |
| 303 | + | g1t session attach --pull 14 --head-commit "$(git rev-parse HEAD)" | |
| 304 | + | g1t session heartbeat | |
| 305 | + | g1t session end --reason done | |
| 306 | + | ``` | |
| 307 | + | ||
| 308 | + | | Command | What it does | | |
| 309 | + | | --- | --- | | |
| 310 | + | | `g1t session start` | Starts a session and prints its id. `--harness` (`claude-code`, `codex`, `cursor`, `copilot`, `other`), `--harness-session-id`, `--issue`, `--pull`, `--branch` (the current one by default) and `--title`. Starting again with the same `--harness-session-id` returns the same session. | | |
| 311 | + | | `g1t session heartbeat` | Says the session is still working, and renews its claims. A session with no heartbeat for 15 minutes reads as idle, and after 2 hours as ended. | | |
| 312 | + | | `g1t session log TEXT` | Records a note. `--kind` takes `note`, `prompt`, `message` or `tool-call`, `--tool` names the tool, and `--key` makes recording the same entry twice do nothing. `-` reads the text from stdin; put `--` before text that starts with a dash. | | |
| 313 | + | | `g1t session attach` | Points the session at an `--issue`, a `--pull`, a `--branch` or its latest `--head-commit`. | | |
| 314 | + | | `g1t session end` | Ends it, with an optional `--reason`, and forgets it. | | |
| 315 | + | | `g1t session list` | The repository's sessions; `--state active`, `--issue`, `--pull`. | | |
| 316 | + | | `g1t session view [ID]` | A session and what it recorded; the current one without an id. | | |
| 317 | + | ||
| 318 | + | The id is kept in `.git/g1t/session`, in the working copy's own git | |
| 319 | + | directory, so each worktree has its own. `G1T_SESSION` overrides it, for | |
| 320 | + | a harness that runs several sessions in one working copy. | |
| 321 | + | ||
| 322 | + | ### Claims and context | |
| 323 | + | ||
| 324 | + | ```sh | |
| 325 | + | g1t work context 12 # everything about #12's work | |
| 326 | + | g1t work claim 12 --note "Taking the parser" --ttl 1h | |
| 327 | + | g1t work release 12 --reason done | |
| 328 | + | g1t work handoff 12 --to ana --note "Parser done; lexer tests still fail." | |
| 329 | + | g1t work list # active claims and sessions here | |
| 330 | + | ``` | |
| 331 | + | ||
| 332 | + | `g1t work context` is the one call to make before starting: the issue and | |
| 333 | + | its definition of done, who has claimed it, its sessions, its pull | |
| 334 | + | requests with their checks, overlaps and place in the merge queue, what it | |
| 335 | + | waits on and what waits on it, its decisions, its last integration failure | |
| 336 | + | and its recent events. | |
| 337 | + | ||
| 338 | + | `g1t work claim` says you are working on an issue. It lasts 30 minutes | |
| 339 | + | unless `--ttl` says otherwise (at most `4h`), and a heartbeat of the session | |
| 340 | + | it was made in renews it. It is made in the current session, if there is | |
| 341 | + | one. If others have claimed the issue too, or it waits on issues that are | |
| 342 | + | not done, the answer says so. `g1t work handoff` ends your claim and asks | |
| 343 | + | `--to` someone, or anyone without it, to take the issue over, with a | |
| 344 | + | `--note` saying where it stands. | |
| 345 | + | ||
| 346 | + | ### Dependencies | |
| 347 | + | ||
| 348 | + | ```sh | |
| 349 | + | g1t work block 12 --by 9 --by 10 # #12 waits on #9 and #10 | |
| 350 | + | g1t work unblock 12 --by 9 | |
| 351 | + | ``` | |
| 352 | + | ||
| 353 | + | These change the issue's `blocked_by` through `update_issue`. An issue | |
| 354 | + | cannot wait on itself, and g1t refuses a cycle. | |
| 355 | + | ||
| 356 | + | ### Following events | |
| 357 | + | ||
| 358 | + | `g1t work events` prints the repository's recent events, oldest first. | |
| 359 | + | With `--follow` it keeps printing them as they happen, without a webhook or | |
| 360 | + | a public address, until you stop it with Ctrl-C: | |
| 361 | + | ||
| 362 | + | ```sh | |
| 363 | + | g1t work events --follow --issue 12 | |
| 364 | + | g1t work events --follow --type integration.failed --type overlap.detected --json | |
| 365 | + | ``` | |
| 366 | + | ||
| 367 | + | | Flag | | | |
| 368 | + | | --- | --- | | |
| 369 | + | | `--type`, `-t` | Only events of this type, such as `work.claimed`; repeat for more. | | |
| 370 | + | | `--issue`, `-i` | Only events about this issue or pull request; repeat for more. | | |
| 371 | + | | `--since`, `-s` | Only events since then: `15m`, `2h`, `1d`, or an RFC 3339 time. | | |
| 372 | + | | `--after` | Start after this event id, to resume where an earlier follow stopped. | | |
| 373 | + | | `--limit`, `-L` | How many past events to show first; 20 by default. | | |
| 374 | + | ||
| 375 | + | Each event is one line: its time, type, issue or pull request, a summary | |
| 376 | + | and its id. With `--json`, each is one compact JSON object on a line of its | |
| 377 | + | own, so a program can read them as they come. Following asks | |
| 378 | + | [`list_events`](/reference/api/repositories/list-events/) with `after` and | |
| 379 | + | `wait`, so the server holds each request until something happens; when a | |
| 380 | + | call fails, `g1t` waits (1 second, then longer, up to 30) and resumes from | |
| 381 | + | the last event it printed. A refused token stops it with exit code `4`. | |
| 382 | + | ||
| 383 | + | ### Why a line is as it is | |
| 384 | + | ||
| 385 | + | ```sh | |
| 386 | + | g1t why src/parser.rs:120 | |
| 387 | + | g1t why src/parser.rs --ref main | |
| 388 | + | ``` | |
| 389 | + | ||
| 390 | + | `g1t why` follows a line to the commit that last changed it, the pull | |
| 391 | + | request that merged that commit, the issue the pull request was for, and | |
| 392 | + | the decisions and sessions recorded on them. A path is read from where you | |
| 393 | + | are in the working copy. | |
| 394 | + | ||
| 395 | + | ### In an agent's hooks | |
| 396 | + | ||
| 397 | + | These commands are made to be run by a harness's hooks in the background: | |
| 398 | + | they never ask anything, they fail at once with exit code `4` when there is | |
| 399 | + | no token, and they give up on a call after 15 seconds. Against a g1t server | |
| 400 | + | older than the CLI, a command it cannot do yet fails saying so. | |
| 284 | 401 | ||
| 285 | 402 | ## Scripting with JSON | |
| 286 | 403 |
| 735 | 735 | ||
| 736 | 736 | ## g1t status | |
| 737 | 737 | ||
| 738 | − | What needs you: your pull requests, reviews asked of you, your issues, failing checks. | |
| 738 | + | What needs you: your pull requests, reviews asked of you, your issues, failing checks, and your sessions and claims here. | |
| 739 | 739 | ||
| 740 | 740 | ```text | |
| 741 | 741 | g1t status [OPTIONS] | |
| 742 | 742 | ``` | |
| 743 | 743 | ||
| 744 | − | Calls [`search`](/reference/api/search/search/), [`list_notifications`](/reference/api/notifications/list-notifications/), [`get_pull_request`](/reference/api/pull-requests/get-pull-request/). | |
| 744 | + | Calls [`search`](/reference/api/search/search/), [`list_notifications`](/reference/api/notifications/list-notifications/), [`get_pull_request`](/reference/api/pull-requests/get-pull-request/), [`list_sessions`](/reference/api/sessions/list-sessions/), [`list_claims`](/reference/api/issues/list-claims/). | |
| 745 | + | ||
| 746 | + | ## g1t session | |
| 747 | + | ||
| 748 | + | Sessions: one run of an agent's harness, recorded as it works. | |
| 749 | + | ||
| 750 | + | ### g1t session start | |
| 751 | + | ||
| 752 | + | Start a session and remember it for this working copy. Prints its id. | |
| 753 | + | ||
| 754 | + | ```text | |
| 755 | + | g1t session start [OPTIONS] | |
| 756 | + | ``` | |
| 757 | + | ||
| 758 | + | | Argument or flag | | | |
| 759 | + | | --- | --- | | |
| 760 | + | | `-i, --issue <ISSUE>` | The issue it works on. | | |
| 761 | + | | `-p, --pull <PULL>` | The pull request it works on. | | |
| 762 | + | | `--harness <HARNESS>` | The harness running it. One of `claude-code`, `codex`, `cursor`, `copilot`, `other`. Default: `other`. | | |
| 763 | + | | `--harness-session-id <ID>` | The harness's own id for the run, which makes starting again return the same session. | | |
| 764 | + | | `-t, --title <TITLE>` | | | |
| 765 | + | | `-b, --branch <BRANCH>` | The branch it works on; the current one when left out. | | |
| 766 | + | ||
| 767 | + | ```text | |
| 768 | + | The id is kept in .git/g1t/session (each worktree has its own); G1T_SESSION overrides it. | |
| 769 | + | Starting again with the same --harness-session-id returns the same session. | |
| 770 | + | ``` | |
| 771 | + | ||
| 772 | + | Calls [`start_session`](/reference/api/sessions/start-session/). | |
| 773 | + | ||
| 774 | + | ### g1t session heartbeat | |
| 775 | + | ||
| 776 | + | Say the current session is still working; renews its claims. | |
| 777 | + | ||
| 778 | + | ```text | |
| 779 | + | g1t session heartbeat [OPTIONS] | |
| 780 | + | ``` | |
| 781 | + | ||
| 782 | + | Calls [`update_session`](/reference/api/sessions/update-session/). | |
| 783 | + | ||
| 784 | + | ### g1t session log | |
| 785 | + | ||
| 786 | + | Record a note, prompt or message in the current session. | |
| 787 | + | ||
| 788 | + | ```text | |
| 789 | + | g1t session log [OPTIONS] <TEXT> | |
| 790 | + | ``` | |
| 791 | + | ||
| 792 | + | | Argument or flag | | | |
| 793 | + | | --- | --- | | |
| 794 | + | | `<TEXT>` | What to record; - reads it from stdin. | | |
| 795 | + | | `-k, --kind <KIND>` | One of `note`, `prompt`, `message`, `tool-call`. Default: `note`. | | |
| 796 | + | | `--tool <TOOL>` | The tool, for a tool_call. | | |
| 797 | + | | `--key <KEY>` | A key that makes recording it again do nothing. | | |
| 798 | + | ||
| 799 | + | Calls [`record_session_entries`](/reference/api/sessions/record-session-entries/). | |
| 800 | + | ||
| 801 | + | ### g1t session attach | |
| 802 | + | ||
| 803 | + | Attach the current session to an issue, a pull request or a branch. | |
| 804 | + | ||
| 805 | + | ```text | |
| 806 | + | g1t session attach [OPTIONS] <--issue <ISSUE>|--pull <PULL>|--branch <BRANCH>|--head-commit <SHA>> | |
| 807 | + | ``` | |
| 808 | + | ||
| 809 | + | | Argument or flag | | | |
| 810 | + | | --- | --- | | |
| 811 | + | | `-i, --issue <ISSUE>` | | | |
| 812 | + | | `-p, --pull <PULL>` | | | |
| 813 | + | | `-b, --branch <BRANCH>` | | | |
| 814 | + | | `--head-commit <SHA>` | The commit it pushed last. | | |
| 815 | + | ||
| 816 | + | Calls [`update_session`](/reference/api/sessions/update-session/). | |
| 817 | + | ||
| 818 | + | ### g1t session end | |
| 819 | + | ||
| 820 | + | End the current session. | |
| 821 | + | ||
| 822 | + | ```text | |
| 823 | + | g1t session end [OPTIONS] | |
| 824 | + | ``` | |
| 825 | + | ||
| 826 | + | | Argument or flag | | | |
| 827 | + | | --- | --- | | |
| 828 | + | | `-r, --reason <REASON>` | Why it ended: done, abandoned, handed_off... | | |
| 829 | + | ||
| 830 | + | Calls [`end_session`](/reference/api/sessions/end-session/). | |
| 831 | + | ||
| 832 | + | ### g1t session list | |
| 833 | + | ||
| 834 | + | List the repository's sessions. | |
| 835 | + | ||
| 836 | + | ```text | |
| 837 | + | g1t session list [OPTIONS] | |
| 838 | + | ``` | |
| 839 | + | ||
| 840 | + | | Argument or flag | | | |
| 841 | + | | --- | --- | | |
| 842 | + | | `-s, --state <STATE>` | Only sessions in this state. One of `active`, `idle`, `ended`. | | |
| 843 | + | | `-i, --issue <ISSUE>` | | | |
| 844 | + | | `-p, --pull <PULL>` | | | |
| 845 | + | | `-L, --limit <LIMIT>` | Default: `20`. | | |
| 846 | + | ||
| 847 | + | Calls [`list_sessions`](/reference/api/sessions/list-sessions/). | |
| 848 | + | ||
| 849 | + | ### g1t session view | |
| 850 | + | ||
| 851 | + | Show a session and what it recorded; the current one when left out. | |
| 852 | + | ||
| 853 | + | ```text | |
| 854 | + | g1t session view [OPTIONS] [ID] | |
| 855 | + | ``` | |
| 856 | + | ||
| 857 | + | | Argument or flag | | | |
| 858 | + | | --- | --- | | |
| 859 | + | | `[ID]` | The session's id (ses_…); the current one when left out. | | |
| 860 | + | ||
| 861 | + | Calls [`get_session`](/reference/api/sessions/get-session/). | |
| 862 | + | ||
| 863 | + | ## g1t work | |
| 864 | + | ||
| 865 | + | Coordinate work on issues: context, claims, hand-offs, dependencies, events. | |
| 866 | + | ||
| 867 | + | ### g1t work context | |
| 868 | + | ||
| 869 | + | Everything about an issue's work: claims, sessions, pull requests, dependencies, decisions. | |
| 870 | + | ||
| 871 | + | ```text | |
| 872 | + | g1t work context [OPTIONS] <NUMBER> | |
| 873 | + | ``` | |
| 874 | + | ||
| 875 | + | | Argument or flag | | | |
| 876 | + | | --- | --- | | |
| 877 | + | | `<NUMBER>` | The issue's number. | | |
| 878 | + | ||
| 879 | + | Calls [`get_work`](/reference/api/issues/get-work/). | |
| 880 | + | ||
| 881 | + | ### g1t work claim | |
| 882 | + | ||
| 883 | + | Claim an issue: say you are working on it. Others are told, never stopped. | |
| 884 | + | ||
| 885 | + | ```text | |
| 886 | + | g1t work claim [OPTIONS] <NUMBER> | |
| 887 | + | ``` | |
| 888 | + | ||
| 889 | + | | Argument or flag | | | |
| 890 | + | | --- | --- | | |
| 891 | + | | `<NUMBER>` | The issue's number. | | |
| 892 | + | | `-n, --note <NOTE>` | What you are doing about it. | | |
| 893 | + | | `--ttl <DURATION>` | How long the claim lasts unless renewed: 30m, 2h (at most 4h). | | |
| 894 | + | ||
| 895 | + | Calls [`claim_issue`](/reference/api/issues/claim-issue/). | |
| 896 | + | ||
| 897 | + | ### g1t work release | |
| 898 | + | ||
| 899 | + | Release your claim on an issue. | |
| 900 | + | ||
| 901 | + | ```text | |
| 902 | + | g1t work release [OPTIONS] <NUMBER> | |
| 903 | + | ``` | |
| 904 | + | ||
| 905 | + | | Argument or flag | | | |
| 906 | + | | --- | --- | | |
| 907 | + | | `<NUMBER>` | | | |
| 908 | + | | `-r, --reason <REASON>` | Why: done, blocked, abandoned... | | |
| 909 | + | ||
| 910 | + | Calls [`release_claim`](/reference/api/issues/release-claim/). | |
| 911 | + | ||
| 912 | + | ### g1t work handoff | |
| 913 | + | ||
| 914 | + | Hand an issue over to someone else, or to anyone. | |
| 915 | + | ||
| 916 | + | ```text | |
| 917 | + | g1t work handoff [OPTIONS] --note <NOTE> <NUMBER> | |
| 918 | + | ``` | |
| 919 | + | ||
| 920 | + | | Argument or flag | | | |
| 921 | + | | --- | --- | | |
| 922 | + | | `<NUMBER>` | | | |
| 923 | + | | `--to <LOGIN>` | Who takes it over; anyone when left out. | | |
| 924 | + | | `-n, --note <NOTE>` | Where it stands and what is left, for whoever takes it. | | |
| 925 | + | ||
| 926 | + | Calls [`hand_off_issue`](/reference/api/issues/hand-off-issue/). | |
| 927 | + | ||
| 928 | + | ### g1t work list | |
| 929 | + | ||
| 930 | + | Active claims and sessions in the repository. | |
| 931 | + | ||
| 932 | + | ```text | |
| 933 | + | g1t work list [OPTIONS] | |
| 934 | + | ``` | |
| 935 | + | ||
| 936 | + | Calls [`list_claims`](/reference/api/issues/list-claims/), [`list_sessions`](/reference/api/sessions/list-sessions/). | |
| 937 | + | ||
| 938 | + | ### g1t work block | |
| 939 | + | ||
| 940 | + | Say an issue waits on others: it is blocked by them. | |
| 941 | + | ||
| 942 | + | ```text | |
| 943 | + | g1t work block [OPTIONS] --by <NUMBER> <NUMBER> | |
| 944 | + | ``` | |
| 945 | + | ||
| 946 | + | | Argument or flag | | | |
| 947 | + | | --- | --- | | |
| 948 | + | | `<NUMBER>` | The issue that waits. | | |
| 949 | + | | `--by <NUMBER>` | The issue it waits on; repeat for more. | | |
| 950 | + | ||
| 951 | + | Calls [`get_issue`](/reference/api/issues/get-issue/), [`update_issue`](/reference/api/issues/update-issue/). | |
| 952 | + | ||
| 953 | + | ### g1t work unblock | |
| 954 | + | ||
| 955 | + | Say an issue no longer waits on others. | |
| 956 | + | ||
| 957 | + | ```text | |
| 958 | + | g1t work unblock [OPTIONS] --by <NUMBER> <NUMBER> | |
| 959 | + | ``` | |
| 960 | + | ||
| 961 | + | | Argument or flag | | | |
| 962 | + | | --- | --- | | |
| 963 | + | | `<NUMBER>` | The issue that waits. | | |
| 964 | + | | `--by <NUMBER>` | The issue it waits on; repeat for more. | | |
| 965 | + | ||
| 966 | + | Calls [`get_issue`](/reference/api/issues/get-issue/), [`update_issue`](/reference/api/issues/update-issue/). | |
| 967 | + | ||
| 968 | + | ### g1t work events | |
| 969 | + | ||
| 970 | + | The repository's events; --follow keeps printing them as they happen. | |
| 971 | + | ||
| 972 | + | ```text | |
| 973 | + | g1t work events [OPTIONS] | |
| 974 | + | ``` | |
| 975 | + | ||
| 976 | + | | Argument or flag | | | |
| 977 | + | | --- | --- | | |
| 978 | + | | `-f, --follow` | Keep printing events as they happen, until stopped. | | |
| 979 | + | | `-t, --type <TYPE>` | Only events of this type; repeat for more. | | |
| 980 | + | | `-i, --issue <NUMBER>` | Only events about this issue or pull request; repeat for more. | | |
| 981 | + | | `-s, --since <SINCE>` | Only events since then: 15m, 2h, 1d, or a time like 2026-10-12T09:00:00Z. | | |
| 982 | + | | `--after <EVENT_ID>` | Start after this event id, as a follow that stopped would resume. | | |
| 983 | + | | `-L, --limit <LIMIT>` | How many past events to show first. Default: `20`. | | |
| 984 | + | ||
| 985 | + | ```text | |
| 986 | + | Examples: | |
| 987 | + | g1t work events --follow --issue 12 | |
| 988 | + | g1t work events --follow --type integration.failed --type overlap.detected --json | |
| 989 | + | With --json, each event is one JSON object on a line of its own. | |
| 990 | + | ``` | |
| 991 | + | ||
| 992 | + | Calls [`list_events`](/reference/api/repositories/list-events/). | |
| 993 | + | ||
| 994 | + | ## g1t why | |
| 995 | + | ||
| 996 | + | Why a file or line is as it is: its commit, pull request, issue, decisions and sessions. | |
| 997 | + | ||
| 998 | + | ```text | |
| 999 | + | g1t why [OPTIONS] <TARGET> | |
| 1000 | + | ``` | |
| 1001 | + | ||
| 1002 | + | | Argument or flag | | | |
| 1003 | + | | --- | --- | | |
| 1004 | + | | `<TARGET>` | A file, or a line of it as PATH:LINE. | | |
| 1005 | + | | `--ref <REF>` | The branch, tag or commit to read it at; the default branch when left out. | | |
| 1006 | + | ||
| 1007 | + | Calls [`why`](/reference/api/repositories/why/). | |
| 745 | 1008 | ||
| 746 | 1009 | ## g1t api | |
| 747 | 1010 |
| 99 | 99 | Deploy(DeployCommand), | |
| 100 | 100 | /// A project's logs and errors. | |
| 101 | 101 | Logs(LogsArgs), | |
| 102 | − | /// What needs you: your pull requests, reviews asked of you, your issues, failing checks. | |
| 102 | + | /// What needs you: your pull requests, reviews asked of you, your issues, failing checks, and your sessions and claims here. | |
| 103 | 103 | Status(StatusArgs), | |
| 104 | + | /// Sessions: one run of an agent's harness, recorded as it works. | |
| 105 | + | #[command(subcommand)] | |
| 106 | + | Session(SessionCommand), | |
| 107 | + | /// Coordinate work on issues: context, claims, hand-offs, dependencies, events. | |
| 108 | + | #[command(subcommand)] | |
| 109 | + | Work(WorkCommand), | |
| 110 | + | /// Why a file or line is as it is: its commit, pull request, issue, decisions and sessions. | |
| 111 | + | Why(WhyArgs), | |
| 104 | 112 | /// Call any API operation or path, with your token. | |
| 105 | 113 | Api(ApiArgs), | |
| 106 | 114 | /// Open the repository, an issue or a pull request in the browser. | |
| ⋯ | |||
| 781 | 789 | pub limit: usize, | |
| 782 | 790 | } | |
| 783 | 791 | ||
| 792 | + | // --- session ---------------------------------------------------------- | |
| 793 | + | ||
| 794 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 795 | + | pub enum Harness { | |
| 796 | + | ClaudeCode, | |
| 797 | + | Codex, | |
| 798 | + | Cursor, | |
| 799 | + | Copilot, | |
| 800 | + | Other, | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | impl Harness { | |
| 804 | + | pub fn name(self) -> &'static str { | |
| 805 | + | match self { | |
| 806 | + | Harness::ClaudeCode => "claude_code", | |
| 807 | + | Harness::Codex => "codex", | |
| 808 | + | Harness::Cursor => "cursor", | |
| 809 | + | Harness::Copilot => "copilot", | |
| 810 | + | Harness::Other => "other", | |
| 811 | + | } | |
| 812 | + | } | |
| 813 | + | } | |
| 814 | + | ||
| 815 | + | #[derive(Debug, Subcommand)] | |
| 816 | + | pub enum SessionCommand { | |
| 817 | + | /// Start a session and remember it for this working copy. Prints its id. | |
| 818 | + | #[command(after_help = "\ | |
| 819 | + | The id is kept in .git/g1t/session (each worktree has its own); G1T_SESSION overrides it. | |
| 820 | + | Starting again with the same --harness-session-id returns the same session.")] | |
| 821 | + | Start(SessionStartArgs), | |
| 822 | + | /// Say the current session is still working; renews its claims. | |
| 823 | + | Heartbeat, | |
| 824 | + | /// Record a note, prompt or message in the current session. | |
| 825 | + | Log(SessionLogArgs), | |
| 826 | + | /// Attach the current session to an issue, a pull request or a branch. | |
| 827 | + | Attach(SessionAttachArgs), | |
| 828 | + | /// End the current session. | |
| 829 | + | End(SessionEndArgs), | |
| 830 | + | /// List the repository's sessions. | |
| 831 | + | List(SessionListArgs), | |
| 832 | + | /// Show a session and what it recorded; the current one when left out. | |
| 833 | + | View(SessionViewArgs), | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | #[derive(Debug, Args)] | |
| 837 | + | pub struct SessionStartArgs { | |
| 838 | + | /// The issue it works on. | |
| 839 | + | #[arg(short, long)] | |
| 840 | + | pub issue: Option<u64>, | |
| 841 | + | /// The pull request it works on. | |
| 842 | + | #[arg(short, long)] | |
| 843 | + | pub pull: Option<u64>, | |
| 844 | + | /// The harness running it. | |
| 845 | + | #[arg(long, value_enum, default_value_t = Harness::Other)] | |
| 846 | + | pub harness: Harness, | |
| 847 | + | /// The harness's own id for the run, which makes starting again return the same session. | |
| 848 | + | #[arg(long, value_name = "ID")] | |
| 849 | + | pub harness_session_id: Option<String>, | |
| 850 | + | #[arg(short, long)] | |
| 851 | + | pub title: Option<String>, | |
| 852 | + | /// The branch it works on; the current one when left out. | |
| 853 | + | #[arg(short, long)] | |
| 854 | + | pub branch: Option<String>, | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 858 | + | pub enum EntryKind { | |
| 859 | + | Note, | |
| 860 | + | Prompt, | |
| 861 | + | Message, | |
| 862 | + | ToolCall, | |
| 863 | + | } | |
| 864 | + | ||
| 865 | + | impl EntryKind { | |
| 866 | + | pub fn name(self) -> &'static str { | |
| 867 | + | match self { | |
| 868 | + | EntryKind::Note => "note", | |
| 869 | + | EntryKind::Prompt => "prompt", | |
| 870 | + | EntryKind::Message => "message", | |
| 871 | + | EntryKind::ToolCall => "tool_call", | |
| 872 | + | } | |
| 873 | + | } | |
| 874 | + | } | |
| 875 | + | ||
| 876 | + | #[derive(Debug, Args)] | |
| 877 | + | pub struct SessionLogArgs { | |
| 878 | + | /// What to record; - reads it from stdin. | |
| 879 | + | pub text: String, | |
| 880 | + | #[arg(short, long, value_enum, default_value_t = EntryKind::Note)] | |
| 881 | + | pub kind: EntryKind, | |
| 882 | + | /// The tool, for a tool_call. | |
| 883 | + | #[arg(long)] | |
| 884 | + | pub tool: Option<String>, | |
| 885 | + | /// A key that makes recording it again do nothing. | |
| 886 | + | #[arg(long)] | |
| 887 | + | pub key: Option<String>, | |
| 888 | + | } | |
| 889 | + | ||
| 890 | + | #[derive(Debug, Args)] | |
| 891 | + | #[command(group(clap::ArgGroup::new("what").required(true).multiple(true).args(["issue", "pull", "branch", "head_commit"])))] | |
| 892 | + | pub struct SessionAttachArgs { | |
| 893 | + | #[arg(short, long)] | |
| 894 | + | pub issue: Option<u64>, | |
| 895 | + | #[arg(short, long)] | |
| 896 | + | pub pull: Option<u64>, | |
| 897 | + | #[arg(short, long)] | |
| 898 | + | pub branch: Option<String>, | |
| 899 | + | /// The commit it pushed last. | |
| 900 | + | #[arg(long, value_name = "SHA")] | |
| 901 | + | pub head_commit: Option<String>, | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | #[derive(Debug, Args)] | |
| 905 | + | pub struct SessionEndArgs { | |
| 906 | + | /// Why it ended: done, abandoned, handed_off... | |
| 907 | + | #[arg(short, long)] | |
| 908 | + | pub reason: Option<String>, | |
| 909 | + | } | |
| 910 | + | ||
| 911 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 912 | + | pub enum SessionState { | |
| 913 | + | Active, | |
| 914 | + | Idle, | |
| 915 | + | Ended, | |
| 916 | + | } | |
| 917 | + | ||
| 918 | + | #[derive(Debug, Args)] | |
| 919 | + | pub struct SessionListArgs { | |
| 920 | + | /// Only sessions in this state. | |
| 921 | + | #[arg(short, long, value_enum)] | |
| 922 | + | pub state: Option<SessionState>, | |
| 923 | + | #[arg(short, long)] | |
| 924 | + | pub issue: Option<u64>, | |
| 925 | + | #[arg(short, long)] | |
| 926 | + | pub pull: Option<u64>, | |
| 927 | + | #[arg(short = 'L', long, default_value_t = 20)] | |
| 928 | + | pub limit: usize, | |
| 929 | + | } | |
| 930 | + | ||
| 931 | + | #[derive(Debug, Args)] | |
| 932 | + | pub struct SessionViewArgs { | |
| 933 | + | /// The session's id (ses_…); the current one when left out. | |
| 934 | + | pub id: Option<String>, | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | // --- work ------------------------------------------------------------- | |
| 938 | + | ||
| 939 | + | #[derive(Debug, Subcommand)] | |
| 940 | + | pub enum WorkCommand { | |
| 941 | + | /// Everything about an issue's work: claims, sessions, pull requests, dependencies, decisions. | |
| 942 | + | Context(WorkNumber), | |
| 943 | + | /// Claim an issue: say you are working on it. Others are told, never stopped. | |
| 944 | + | Claim(WorkClaimArgs), | |
| 945 | + | /// Release your claim on an issue. | |
| 946 | + | Release(WorkReleaseArgs), | |
| 947 | + | /// Hand an issue over to someone else, or to anyone. | |
| 948 | + | Handoff(WorkHandoffArgs), | |
| 949 | + | /// Active claims and sessions in the repository. | |
| 950 | + | List, | |
| 951 | + | /// Say an issue waits on others: it is blocked by them. | |
| 952 | + | Block(WorkBlockArgs), | |
| 953 | + | /// Say an issue no longer waits on others. | |
| 954 | + | Unblock(WorkBlockArgs), | |
| 955 | + | /// The repository's events; --follow keeps printing them as they happen. | |
| 956 | + | #[command(after_help = "\ | |
| 957 | + | Examples: | |
| 958 | + | g1t work events --follow --issue 12 | |
| 959 | + | g1t work events --follow --type integration.failed --type overlap.detected --json | |
| 960 | + | With --json, each event is one JSON object on a line of its own.")] | |
| 961 | + | Events(WorkEventsArgs), | |
| 962 | + | } | |
| 963 | + | ||
| 964 | + | #[derive(Debug, Args)] | |
| 965 | + | pub struct WorkNumber { | |
| 966 | + | /// The issue's number. | |
| 967 | + | pub number: u64, | |
| 968 | + | } | |
| 969 | + | ||
| 970 | + | #[derive(Debug, Args)] | |
| 971 | + | pub struct WorkClaimArgs { | |
| 972 | + | /// The issue's number. | |
| 973 | + | pub number: u64, | |
| 974 | + | /// What you are doing about it. | |
| 975 | + | #[arg(short, long)] | |
| 976 | + | pub note: Option<String>, | |
| 977 | + | /// How long the claim lasts unless renewed: 30m, 2h (at most 4h). | |
| 978 | + | #[arg(long, value_name = "DURATION", value_parser = parse_ttl)] | |
| 979 | + | pub ttl: Option<u64>, | |
| 980 | + | } | |
| 981 | + | ||
| 982 | + | #[derive(Debug, Args)] | |
| 983 | + | pub struct WorkReleaseArgs { | |
| 984 | + | pub number: u64, | |
| 985 | + | /// Why: done, blocked, abandoned... | |
| 986 | + | #[arg(short, long)] | |
| 987 | + | pub reason: Option<String>, | |
| 988 | + | } | |
| 989 | + | ||
| 990 | + | #[derive(Debug, Args)] | |
| 991 | + | pub struct WorkHandoffArgs { | |
| 992 | + | pub number: u64, | |
| 993 | + | /// Who takes it over; anyone when left out. | |
| 994 | + | #[arg(long, value_name = "LOGIN")] | |
| 995 | + | pub to: Option<String>, | |
| 996 | + | /// Where it stands and what is left, for whoever takes it. | |
| 997 | + | #[arg(short, long)] | |
| 998 | + | pub note: String, | |
| 999 | + | } | |
| 1000 | + | ||
| 1001 | + | #[derive(Debug, Args)] | |
| 1002 | + | pub struct WorkBlockArgs { | |
| 1003 | + | /// The issue that waits. | |
| 1004 | + | pub number: u64, | |
| 1005 | + | /// The issue it waits on; repeat for more. | |
| 1006 | + | #[arg(long = "by", value_name = "NUMBER", required = true)] | |
| 1007 | + | pub by: Vec<u64>, | |
| 1008 | + | } | |
| 1009 | + | ||
| 1010 | + | #[derive(Debug, Args)] | |
| 1011 | + | pub struct WorkEventsArgs { | |
| 1012 | + | /// Keep printing events as they happen, until stopped. | |
| 1013 | + | #[arg(short, long)] | |
| 1014 | + | pub follow: bool, | |
| 1015 | + | /// Only events of this type; repeat for more. | |
| 1016 | + | #[arg(short = 't', long = "type", value_name = "TYPE")] | |
| 1017 | + | pub types: Vec<String>, | |
| 1018 | + | /// Only events about this issue or pull request; repeat for more. | |
| 1019 | + | #[arg(short, long = "issue", value_name = "NUMBER")] | |
| 1020 | + | pub issues: Vec<u64>, | |
| 1021 | + | /// Only events since then: 15m, 2h, 1d, or a time like 2026-10-12T09:00:00Z. | |
| 1022 | + | #[arg(short, long)] | |
| 1023 | + | pub since: Option<String>, | |
| 1024 | + | /// Start after this event id, as a follow that stopped would resume. | |
| 1025 | + | #[arg(long, value_name = "EVENT_ID")] | |
| 1026 | + | pub after: Option<String>, | |
| 1027 | + | /// How many past events to show first. | |
| 1028 | + | #[arg(short = 'L', long, default_value_t = 20)] | |
| 1029 | + | pub limit: usize, | |
| 1030 | + | } | |
| 1031 | + | ||
| 1032 | + | /// `30m`, `2h`, `90` (minutes) as minutes, from 1 minute to 4 hours. | |
| 1033 | + | pub fn parse_ttl(text: &str) -> Result<u64, String> { | |
| 1034 | + | let text = text.trim(); | |
| 1035 | + | let (number, unit) = text.split_at(text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len())); | |
| 1036 | + | let wrong = || format!("`{text}` is not a duration: write it as 30m or 2h"); | |
| 1037 | + | let number: u64 = number.parse().map_err(|_| wrong())?; | |
| 1038 | + | let minutes = match unit { | |
| 1039 | + | "" | "m" | "min" => number, | |
| 1040 | + | "h" => number * 60, | |
| 1041 | + | _ => return Err(wrong()), | |
| 1042 | + | }; | |
| 1043 | + | if minutes == 0 || minutes > 240 { | |
| 1044 | + | return Err("a claim lasts from 1 minute to 4 hours".to_owned()); | |
| 1045 | + | } | |
| 1046 | + | Ok(minutes) | |
| 1047 | + | } | |
| 1048 | + | ||
| 1049 | + | // --- why -------------------------------------------------------------- | |
| 1050 | + | ||
| 1051 | + | #[derive(Debug, Args)] | |
| 1052 | + | pub struct WhyArgs { | |
| 1053 | + | /// A file, or a line of it as PATH:LINE. | |
| 1054 | + | pub target: String, | |
| 1055 | + | /// The branch, tag or commit to read it at; the default branch when left out. | |
| 1056 | + | #[arg(long = "ref", value_name = "REF")] | |
| 1057 | + | pub reference: Option<String>, | |
| 1058 | + | } | |
| 1059 | + | ||
| 784 | 1060 | // --- status, api, browse --------------------------------------------- | |
| 785 | 1061 | ||
| 786 | 1062 | #[derive(Debug, Args)] | |
| 71 | 71 | } | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | + | /// The same client, giving up sooner: for commands an agent's hooks run | |
| 75 | + | /// in the background, which must not hang on a slow network. | |
| 76 | + | pub fn with_timeouts(mut self, connect: Duration, read: Duration) -> Client { | |
| 77 | + | self.agent = ureq::AgentBuilder::new() | |
| 78 | + | .timeout_connect(connect) | |
| 79 | + | .timeout_read(read) | |
| 80 | + | .timeout_write(read) | |
| 81 | + | .user_agent(&format!("g1t-cli/{}", env!("CARGO_PKG_VERSION"))) | |
| 82 | + | .redirects(5) | |
| 83 | + | .build(); | |
| 84 | + | self | |
| 85 | + | } | |
| 86 | + | ||
| 74 | 87 | pub fn has_token(&self) -> bool { | |
| 75 | 88 | self.token.is_some() | |
| 76 | 89 | } | |
| ⋯ | |||
| 175 | 188 | ) -> Result<Value> { | |
| 176 | 189 | let bytes = body.map(|b| serde_json::to_vec(b).expect("json")); | |
| 177 | 190 | let reply = self.raw(method, path, query, &[], bytes)?; | |
| 191 | + | if older_server(&reply) { | |
| 192 | + | return Err(Error::failed(format!( | |
| 193 | + | "{} has no {method} {path}: the g1t server is older than this CLI ({}), and cannot do this yet.", | |
| 194 | + | self.host.api, | |
| 195 | + | env!("CARGO_PKG_VERSION") | |
| 196 | + | ))); | |
| 197 | + | } | |
| 178 | 198 | if !(200..300).contains(&reply.status) { | |
| 179 | 199 | return Err(self.api_error(&reply)); | |
| 180 | 200 | } | |
| ⋯ | |||
| 231 | 251 | } | |
| 232 | 252 | } | |
| 233 | 253 | ||
| 254 | + | /// Whether the API answered that it has no such route at all, as a server | |
| 255 | + | /// older than the CLI does for an operation it does not have, rather than | |
| 256 | + | /// that the thing asked about was not found. | |
| 257 | + | pub fn older_server(reply: &Reply) -> bool { | |
| 258 | + | reply.status == 404 | |
| 259 | + | && reply | |
| 260 | + | .json() | |
| 261 | + | .is_some_and(|body| body["error"]["message"].as_str() == Some("No such endpoint.")) | |
| 262 | + | } | |
| 263 | + | ||
| 234 | 264 | /// One call to an operation, its path filled in as it is built. | |
| 235 | 265 | pub struct Call<'a> { | |
| 236 | 266 | client: &'a Client, | |
| 298 | 298 | fn list_ops(ctx: &Ctx, query: &str) -> Result<()> { | |
| 299 | 299 | let q = query.to_lowercase(); | |
| 300 | 300 | let mapped = crate::mapping::by_operation(); | |
| 301 | − | let found: Vec<&Operation> = ops::OPERATIONS | |
| 302 | − | .iter() | |
| 301 | + | let found: Vec<&Operation> = ops::all() | |
| 303 | 302 | .filter(|op| { | |
| 304 | 303 | q.is_empty() | |
| 305 | 304 | || op.id.contains(&q) |
| 11 | 11 | pub mod repo; | |
| 12 | 12 | pub mod run; | |
| 13 | 13 | pub mod secret; | |
| 14 | + | pub mod session; | |
| 14 | 15 | pub mod status; | |
| 16 | + | pub mod why; | |
| 17 | + | pub mod work; | |
| 15 | 18 | ||
| 16 | 19 | use clap::CommandFactory; | |
| 17 | 20 | ||
| ⋯ | |||
| 40 | 43 | Command::Deploy(command) => deploy::run(&Ctx::new(cli.global)?, command), | |
| 41 | 44 | Command::Logs(args) => logs::run(&Ctx::new(cli.global)?, args), | |
| 42 | 45 | Command::Status(_) => status::run(&Ctx::new(cli.global)?), | |
| 46 | + | Command::Session(command) => session::run(&Ctx::new(cli.global)?, command), | |
| 47 | + | Command::Work(command) => work::run(&Ctx::new(cli.global)?, command), | |
| 48 | + | Command::Why(args) => why::run(&Ctx::new(cli.global)?, args), | |
| 43 | 49 | Command::Api(args) => api::run(&Ctx::new(cli.global)?, args), | |
| 44 | 50 | Command::Browse(args) => browse::run(&Ctx::new(cli.global)?, args), | |
| 45 | 51 | } | |
| ⋯ | |||
| 54 | 60 | } | |
| 55 | 61 | } | |
| 56 | 62 | ||
| 63 | + | /// The repository and a signed-in client for the coordination commands | |
| 64 | + | /// (`g1t session`, `g1t work`, `g1t why`). Agents' hooks run them in the | |
| 65 | + | /// background, so they fail at once without a token (exit code 4), never | |
| 66 | + | /// ask anything, and give up on a call after `read` rather than hang. | |
| 67 | + | pub fn coordination_client( | |
| 68 | + | ctx: &Ctx, | |
| 69 | + | read: std::time::Duration, | |
| 70 | + | ) -> Result<(crate::repo::Repo, crate::client::Client)> { | |
| 71 | + | let (repo, client) = ctx.repo_client()?; | |
| 72 | + | if !client.has_token() { | |
| 73 | + | return Err(crate::context::not_signed_in(&client.host)); | |
| 74 | + | } | |
| 75 | + | Ok((repo, client.with_timeouts(std::time::Duration::from_secs(5), read))) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// How long a coordination command waits for an answer. | |
| 79 | + | pub const QUICK: std::time::Duration = std::time::Duration::from_secs(15); | |
| 80 | + | ||
| 81 | + | /// Who a session, claim or decision belongs to: its `actor` (a login, or | |
| 82 | + | /// an account with one), or its `user`. | |
| 83 | + | pub fn login_of(value: &serde_json::Value) -> String { | |
| 84 | + | for key in ["actor", "user", "author"] { | |
| 85 | + | let who = &value[key]; | |
| 86 | + | if let Some(login) = who | |
| 87 | + | .as_str() | |
| 88 | + | .or_else(|| who["username"].as_str()) | |
| 89 | + | .or_else(|| who["login"].as_str()) | |
| 90 | + | .filter(|login| !login.is_empty()) | |
| 91 | + | { | |
| 92 | + | return login.to_owned(); | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | String::new() | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// The object under `key` when the answer wraps it, or the answer itself. | |
| 99 | + | pub fn unwrap_key(value: serde_json::Value, key: &str) -> serde_json::Value { | |
| 100 | + | match value.get(key) { | |
| 101 | + | Some(inner) if inner.is_object() => inner.clone(), | |
| 102 | + | _ => value, | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 57 | 106 | /// The items of a list answer, which comes bare or under a key. | |
| 58 | 107 | pub fn items(value: serde_json::Value, key: &str) -> Vec<serde_json::Value> { | |
| 59 | 108 | match value { | |
| 1 | + | //! `g1t session`: one run of an agent's harness, recorded as it works. | |
| 2 | + | //! | |
| 3 | + | //! The session a working copy is in is kept in `<git dir>/g1t/session`, | |
| 4 | + | //! where `<git dir>` is the worktree's own (`git rev-parse | |
| 5 | + | //! --absolute-git-dir`), so each worktree has its own; `G1T_SESSION` | |
| 6 | + | //! overrides it. `g1t work claim` attaches its claim to it. | |
| 7 | + | ||
| 8 | + | use serde_json::{Value, json}; | |
| 9 | + | use std::io::Read; | |
| 10 | + | use std::path::PathBuf; | |
| 11 | + | ||
| 12 | + | use crate::cli::{ | |
| 13 | + | SessionAttachArgs, SessionCommand, SessionEndArgs, SessionListArgs, SessionLogArgs, | |
| 14 | + | SessionStartArgs, SessionState, SessionViewArgs, | |
| 15 | + | }; | |
| 16 | + | use crate::client::Client; | |
| 17 | + | use crate::context::Ctx; | |
| 18 | + | use crate::error::{Error, Result}; | |
| 19 | + | use crate::output::{Cell, Output, ago, s}; | |
| 20 | + | use crate::repo::Repo; | |
| 21 | + | ||
| 22 | + | pub fn run(ctx: &Ctx, command: SessionCommand) -> Result<()> { | |
| 23 | + | match command { | |
| 24 | + | SessionCommand::Start(args) => start(ctx, args), | |
| 25 | + | SessionCommand::Heartbeat => heartbeat(ctx), | |
| 26 | + | SessionCommand::Log(args) => log(ctx, args), | |
| 27 | + | SessionCommand::Attach(args) => attach(ctx, args), | |
| 28 | + | SessionCommand::End(args) => end(ctx, args), | |
| 29 | + | SessionCommand::List(args) => list(ctx, args), | |
| 30 | + | SessionCommand::View(args) => view(ctx, args), | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Where this working copy's session is kept: `<git dir>/g1t/session`. | |
| 35 | + | fn file() -> Option<PathBuf> { | |
| 36 | + | crate::git::run(&["rev-parse", "--absolute-git-dir"]) | |
| 37 | + | .ok() | |
| 38 | + | .filter(|dir| !dir.is_empty()) | |
| 39 | + | .map(|dir| PathBuf::from(dir).join("g1t").join("session")) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// The session id in `G1T_SESSION`, else the one kept in `file`. | |
| 43 | + | fn chosen(env: Option<String>, file: Option<PathBuf>) -> Option<String> { | |
| 44 | + | env.map(|id| id.trim().to_owned()) | |
| 45 | + | .filter(|id| !id.is_empty()) | |
| 46 | + | .or_else(|| { | |
| 47 | + | let text = std::fs::read_to_string(file?).ok()?; | |
| 48 | + | let id = text.lines().next()?.trim().to_owned(); | |
| 49 | + | (!id.is_empty()).then_some(id) | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// The current session's id, if there is one. | |
| 54 | + | pub fn current() -> Option<String> { | |
| 55 | + | chosen(std::env::var("G1T_SESSION").ok(), file()) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | fn required() -> Result<String> { | |
| 59 | + | current().ok_or_else(|| { | |
| 60 | + | Error::usage("No session here: start one with `g1t session start`, or set G1T_SESSION.") | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fn remember(id: &str) -> Result<()> { | |
| 65 | + | let Some(path) = file() else { | |
| 66 | + | // Outside a working copy there is nowhere to keep it; G1T_SESSION does. | |
| 67 | + | return Ok(()); | |
| 68 | + | }; | |
| 69 | + | if let Some(dir) = path.parent() { | |
| 70 | + | std::fs::create_dir_all(dir)?; | |
| 71 | + | } | |
| 72 | + | std::fs::write(&path, format!("{id}\n"))?; | |
| 73 | + | Ok(()) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Forgets the kept session, when it is `id`. | |
| 77 | + | fn forget(id: &str) { | |
| 78 | + | if let Some(path) = file() | |
| 79 | + | && chosen(None, Some(path.clone())).as_deref() == Some(id) | |
| 80 | + | { | |
| 81 | + | let _ = std::fs::remove_file(path); | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// The session in an answer that may wrap it (`{session, entries}`). | |
| 86 | + | fn session_of(found: Value) -> Value { | |
| 87 | + | super::unwrap_key(found, "session") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | fn start(ctx: &Ctx, args: SessionStartArgs) -> Result<()> { | |
| 91 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 92 | + | let branch = args.branch.clone().or_else(crate::git::current_branch); | |
| 93 | + | let head = crate::git::run(&["rev-parse", "HEAD"]).ok().filter(|h| !h.is_empty()); | |
| 94 | + | let found = client | |
| 95 | + | .op("start_session") | |
| 96 | + | .repo(&repo) | |
| 97 | + | .field("harness", args.harness.name()) | |
| 98 | + | .field_opt("harness_session_id", args.harness_session_id.clone()) | |
| 99 | + | .field_opt("issue", args.issue) | |
| 100 | + | .field_opt("pull", args.pull) | |
| 101 | + | .field_opt("branch", branch) | |
| 102 | + | .field_opt("title", args.title.clone()) | |
| 103 | + | .field_opt("head_commit", head) | |
| 104 | + | .send()?; | |
| 105 | + | let session = session_of(found); | |
| 106 | + | let id = s(&session, "id").to_owned(); | |
| 107 | + | if id.is_empty() { | |
| 108 | + | return Err(Error::failed("The API did not say which session it started.")); | |
| 109 | + | } | |
| 110 | + | remember(&id)?; | |
| 111 | + | if ctx.out.structured() { | |
| 112 | + | return ctx.out.emit(&session); | |
| 113 | + | } | |
| 114 | + | println!("{id}"); | |
| 115 | + | Ok(()) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | fn heartbeat(ctx: &Ctx) -> Result<()> { | |
| 119 | + | let id = required()?; | |
| 120 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 121 | + | let found = client.op("update_session").repo(&repo).arg("id", &id).send()?; | |
| 122 | + | if ctx.out.structured() { | |
| 123 | + | return ctx.out.emit(&session_of(found)); | |
| 124 | + | } | |
| 125 | + | Ok(()) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | fn log(ctx: &Ctx, args: SessionLogArgs) -> Result<()> { | |
| 129 | + | let id = required()?; | |
| 130 | + | let text = if args.text == "-" { | |
| 131 | + | let mut text = String::new(); | |
| 132 | + | std::io::stdin().read_to_string(&mut text)?; | |
| 133 | + | text | |
| 134 | + | } else { | |
| 135 | + | args.text.clone() | |
| 136 | + | }; | |
| 137 | + | if text.trim().is_empty() { | |
| 138 | + | return Err(Error::usage("Nothing to record: give the text.")); | |
| 139 | + | } | |
| 140 | + | let mut entry = json!({ "kind": args.kind.name(), "text": text.trim_end() }); | |
| 141 | + | if let Some(tool) = &args.tool { | |
| 142 | + | entry["tool"] = json!(tool); | |
| 143 | + | } | |
| 144 | + | if let Some(key) = &args.key { | |
| 145 | + | entry["key"] = json!(key); | |
| 146 | + | } | |
| 147 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 148 | + | let found = client | |
| 149 | + | .op("record_session_entries") | |
| 150 | + | .repo(&repo) | |
| 151 | + | .arg("id", &id) | |
| 152 | + | .field("entries", json!([entry])) | |
| 153 | + | .send()?; | |
| 154 | + | if ctx.out.structured() { | |
| 155 | + | return ctx.out.emit(&found); | |
| 156 | + | } | |
| 157 | + | Ok(()) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// Where a session is: `#12`, `pull #14`, its branch. | |
| 161 | + | fn place(session: &Value) -> String { | |
| 162 | + | let mut parts = Vec::new(); | |
| 163 | + | if let Some(issue) = session["issue"].as_u64() { | |
| 164 | + | parts.push(format!("#{issue}")); | |
| 165 | + | } | |
| 166 | + | if let Some(pull) = session["pull"].as_u64() { | |
| 167 | + | parts.push(format!("pull #{pull}")); | |
| 168 | + | } | |
| 169 | + | if let Some(branch) = session["branch"].as_str().filter(|b| !b.is_empty()) { | |
| 170 | + | parts.push(branch.to_owned()); | |
| 171 | + | } | |
| 172 | + | parts.join(", ") | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | fn attach(ctx: &Ctx, args: SessionAttachArgs) -> Result<()> { | |
| 176 | + | let id = required()?; | |
| 177 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 178 | + | let found = client | |
| 179 | + | .op("update_session") | |
| 180 | + | .repo(&repo) | |
| 181 | + | .arg("id", &id) | |
| 182 | + | .field_opt("issue", args.issue) | |
| 183 | + | .field_opt("pull", args.pull) | |
| 184 | + | .field_opt("branch", args.branch.clone()) | |
| 185 | + | .field_opt("head_commit", args.head_commit.clone()) | |
| 186 | + | .send()?; | |
| 187 | + | let session = session_of(found); | |
| 188 | + | if ctx.out.structured() { | |
| 189 | + | return ctx.out.emit(&session); | |
| 190 | + | } | |
| 191 | + | eprintln!("Session {id} is on {}.", place(&session)); | |
| 192 | + | Ok(()) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | fn end(ctx: &Ctx, args: SessionEndArgs) -> Result<()> { | |
| 196 | + | let id = required()?; | |
| 197 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 198 | + | let found = client | |
| 199 | + | .op("end_session") | |
| 200 | + | .repo(&repo) | |
| 201 | + | .arg("id", &id) | |
| 202 | + | .field_opt("reason", args.reason.clone()) | |
| 203 | + | .send()?; | |
| 204 | + | forget(&id); | |
| 205 | + | if ctx.out.structured() { | |
| 206 | + | return ctx.out.emit(&session_of(found)); | |
| 207 | + | } | |
| 208 | + | eprintln!("Ended session {id}."); | |
| 209 | + | Ok(()) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | pub fn fetch_list( | |
| 213 | + | client: &Client, | |
| 214 | + | repo: &Repo, | |
| 215 | + | state: Option<&str>, | |
| 216 | + | issue: Option<u64>, | |
| 217 | + | pull: Option<u64>, | |
| 218 | + | ) -> Result<Vec<Value>> { | |
| 219 | + | let found = client | |
| 220 | + | .op("list_sessions") | |
| 221 | + | .repo(repo) | |
| 222 | + | .query_opt("state", state) | |
| 223 | + | .query_opt("issue", issue) | |
| 224 | + | .query_opt("pull", pull) | |
| 225 | + | .send()?; | |
| 226 | + | Ok(super::items(found, "sessions")) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | fn list(ctx: &Ctx, args: SessionListArgs) -> Result<()> { | |
| 230 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 231 | + | let state = args.state.map(|state| match state { | |
| 232 | + | SessionState::Active => "active", | |
| 233 | + | SessionState::Idle => "idle", | |
| 234 | + | SessionState::Ended => "ended", | |
| 235 | + | }); | |
| 236 | + | let mut sessions = fetch_list(&client, &repo, state, args.issue, args.pull)?; | |
| 237 | + | sessions.truncate(args.limit); | |
| 238 | + | if ctx.out.structured() { | |
| 239 | + | return ctx.out.emit(&Value::Array(sessions)); | |
| 240 | + | } | |
| 241 | + | if sessions.is_empty() { | |
| 242 | + | eprintln!("No {} sessions in {repo}.", state.unwrap_or("matching")); | |
| 243 | + | return Ok(()); | |
| 244 | + | } | |
| 245 | + | print_list(&ctx.out, &sessions, current().as_deref()) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | pub fn print_list(out: &Output, sessions: &[Value], mine: Option<&str>) -> Result<()> { | |
| 249 | + | let rows: Vec<Vec<Cell>> = sessions | |
| 250 | + | .iter() | |
| 251 | + | .map(|session| { | |
| 252 | + | let id = s(session, "id"); | |
| 253 | + | let marked = if Some(id) == mine { format!("{id} *") } else { id.to_owned() }; | |
| 254 | + | vec![ | |
| 255 | + | Cell::new(marked), | |
| 256 | + | Cell::styled(s(session, "state"), out.state(s(session, "state"))), | |
| 257 | + | Cell::new(super::login_of(session)), | |
| 258 | + | Cell::new(s(session, "harness")), | |
| 259 | + | Cell::new(place(session)), | |
| 260 | + | Cell::styled(ago(s(session, "last_seen_at")), out.dim(&ago(s(session, "last_seen_at")))), | |
| 261 | + | ] | |
| 262 | + | }) | |
| 263 | + | .collect(); | |
| 264 | + | out.table(&["session", "state", "who", "harness", "on", "last seen"], &rows) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | fn view(ctx: &Ctx, args: SessionViewArgs) -> Result<()> { | |
| 268 | + | let id = match args.id { | |
| 269 | + | Some(id) => id, | |
| 270 | + | None => required()?, | |
| 271 | + | }; | |
| 272 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 273 | + | let found = client.op("get_session").repo(&repo).arg("id", &id).send()?; | |
| 274 | + | let entries = found | |
| 275 | + | .get("entries") | |
| 276 | + | .cloned() | |
| 277 | + | .unwrap_or_else(|| json!([])); | |
| 278 | + | let mut session = session_of(found); | |
| 279 | + | if let Some(map) = session.as_object_mut() { | |
| 280 | + | map.insert("entries".into(), entries); | |
| 281 | + | } | |
| 282 | + | if ctx.out.structured() { | |
| 283 | + | return ctx.out.emit(&session); | |
| 284 | + | } | |
| 285 | + | let out = &ctx.out; | |
| 286 | + | let title = s(&session, "title"); | |
| 287 | + | println!( | |
| 288 | + | "{} {}", | |
| 289 | + | out.bold(if title.is_empty() { s(&session, "id") } else { title }), | |
| 290 | + | out.dim(&format!("{repo} {}", s(&session, "id"))) | |
| 291 | + | ); | |
| 292 | + | let mut facts = vec![out.state(s(&session, "state"))]; | |
| 293 | + | let who = super::login_of(&session); | |
| 294 | + | if !who.is_empty() { | |
| 295 | + | facts.push(format!("{who} with {}", s(&session, "harness"))); | |
| 296 | + | } | |
| 297 | + | facts.push(format!("started {}", ago(s(&session, "started_at")))); | |
| 298 | + | if let Some(seen) = session["last_seen_at"].as_str() { | |
| 299 | + | facts.push(format!("last seen {}", ago(seen))); | |
| 300 | + | } | |
| 301 | + | println!("{}", facts.join(" · ")); | |
| 302 | + | let on = place(&session); | |
| 303 | + | if !on.is_empty() { | |
| 304 | + | println!("{} {on}", out.dim("On:")); | |
| 305 | + | } | |
| 306 | + | if let Some(head) = session["head_commit"].as_str() { | |
| 307 | + | println!("{} {}", out.dim("Head:"), head.get(..12).unwrap_or(head)); | |
| 308 | + | } | |
| 309 | + | if let Some(reason) = session["end_reason"].as_str() { | |
| 310 | + | println!("{} {reason}", out.dim("Ended:")); | |
| 311 | + | } | |
| 312 | + | let entries = session["entries"].as_array().cloned().unwrap_or_default(); | |
| 313 | + | if !entries.is_empty() { | |
| 314 | + | println!(); | |
| 315 | + | } | |
| 316 | + | for entry in &entries { | |
| 317 | + | let kind = s(entry, "kind"); | |
| 318 | + | let label = match entry["tool"].as_str() { | |
| 319 | + | Some(tool) => format!("{kind} {tool}"), | |
| 320 | + | None => kind.to_owned(), | |
| 321 | + | }; | |
| 322 | + | let text = s(entry, "text").trim(); | |
| 323 | + | let first = text.lines().next().unwrap_or(""); | |
| 324 | + | println!("{} {first}", out.dim(&format!("{label:>10}"))); | |
| 325 | + | } | |
| 326 | + | Ok(()) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | #[cfg(test)] | |
| 330 | + | mod tests { | |
| 331 | + | use super::*; | |
| 332 | + | ||
| 333 | + | #[test] | |
| 334 | + | fn the_environment_overrides_the_kept_session() { | |
| 335 | + | let dir = std::env::temp_dir().join(format!("g1t-session-{}", std::process::id())); | |
| 336 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 337 | + | let path = dir.join("session"); | |
| 338 | + | std::fs::write(&path, "ses_kept\n").unwrap(); | |
| 339 | + | assert_eq!(chosen(None, Some(path.clone())).as_deref(), Some("ses_kept")); | |
| 340 | + | assert_eq!(chosen(Some("ses_env".into()), Some(path.clone())).as_deref(), Some("ses_env")); | |
| 341 | + | assert_eq!(chosen(Some(" ".into()), Some(path.clone())).as_deref(), Some("ses_kept")); | |
| 342 | + | std::fs::write(&path, "\n").unwrap(); | |
| 343 | + | assert_eq!(chosen(None, Some(path)).as_deref(), None); | |
| 344 | + | assert_eq!(chosen(None, None), None); | |
| 345 | + | let _ = std::fs::remove_dir_all(dir); | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | #[test] | |
| 349 | + | fn a_session_says_where_it_is() { | |
| 350 | + | assert_eq!(place(&json!({ "issue": 12, "pull": 14, "branch": "fix-x" })), "#12, pull #14, fix-x"); | |
| 351 | + | assert_eq!(place(&json!({ "issue": null, "branch": "" })), ""); | |
| 352 | + | } | |
| 353 | + | } |
| 58 | 58 | }) | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | /// Your active sessions and claims in the current repository, when there | |
| 62 | + | /// is one: none outside a repository, or from a server without them. | |
| 63 | + | fn here(ctx: &Ctx, me: &str) -> (Option<String>, Vec<Value>, Vec<Value>) { | |
| 64 | + | let Ok((repo, client)) = ctx.repo_client() else { | |
| 65 | + | return (None, Vec::new(), Vec::new()); | |
| 66 | + | }; | |
| 67 | + | if !client.has_token() { | |
| 68 | + | return (None, Vec::new(), Vec::new()); | |
| 69 | + | } | |
| 70 | + | let client = client.with_timeouts(std::time::Duration::from_secs(5), super::QUICK); | |
| 71 | + | let fetch = |op: &str, key: &str| -> Vec<Value> { | |
| 72 | + | client | |
| 73 | + | .op(op) | |
| 74 | + | .repo(&repo) | |
| 75 | + | .query("state", "active") | |
| 76 | + | .send() | |
| 77 | + | .map(|found| super::items(found, key)) | |
| 78 | + | .unwrap_or_default() | |
| 79 | + | .into_iter() | |
| 80 | + | .filter(|item| super::login_of(item).eq_ignore_ascii_case(me)) | |
| 81 | + | .collect() | |
| 82 | + | }; | |
| 83 | + | let (sessions, claims) = std::thread::scope(|scope| { | |
| 84 | + | let sessions = scope.spawn(|| fetch("list_sessions", "sessions")); | |
| 85 | + | let claims = scope.spawn(|| fetch("list_claims", "claims")); | |
| 86 | + | (sessions.join().unwrap_or_default(), claims.join().unwrap_or_default()) | |
| 87 | + | }); | |
| 88 | + | (Some(repo.to_string()), sessions, claims) | |
| 89 | + | } | |
| 90 | + | ||
| 61 | 91 | fn section( | |
| 62 | 92 | out: &Output, | |
| 63 | 93 | title: &str, | |
| ⋯ | |||
| 78 | 108 | pub fn run(ctx: &Ctx) -> Result<()> { | |
| 79 | 109 | let client = ctx.signed_in_client()?; | |
| 80 | 110 | let me = ctx.me(&client)?; | |
| 81 | − | let (pulls, reviews, assigned, ci) = std::thread::scope(|scope| { | |
| 111 | + | let (pulls, reviews, assigned, ci, here) = std::thread::scope(|scope| { | |
| 82 | 112 | let pulls = scope.spawn(|| search_pulls(&client, &me).map(|p| with_checks(&client, p))); | |
| 83 | 113 | let reviews = scope.spawn(|| notifications(&client, "review_requested")); | |
| 84 | 114 | let assigned = scope.spawn(|| notifications(&client, "assign")); | |
| 85 | 115 | let ci = scope.spawn(|| notifications(&client, "ci_activity")); | |
| 86 | − | (pulls.join(), reviews.join(), assigned.join(), ci.join()) | |
| 116 | + | let here = scope.spawn(|| here(ctx, &me)); | |
| 117 | + | (pulls.join(), reviews.join(), assigned.join(), ci.join(), here.join()) | |
| 87 | 118 | }); | |
| 119 | + | let (repo, sessions, claims) = here.unwrap_or((None, Vec::new(), Vec::new())); | |
| 88 | 120 | let unwrap = |r: std::thread::Result<Result<Vec<Value>>>| -> Result<Vec<Value>> { | |
| 89 | 121 | r.unwrap_or_else(|_| Err(crate::error::Error::failed("A lookup failed."))) | |
| 90 | 122 | }; | |
| ⋯ | |||
| 113 | 145 | "assigned": assigned, | |
| 114 | 146 | "failing_checks": failing, | |
| 115 | 147 | "ci_failures": ci, | |
| 148 | + | "repo": repo, | |
| 149 | + | "sessions": sessions, | |
| 150 | + | "claims": claims, | |
| 116 | 151 | })); | |
| 117 | 152 | } | |
| 118 | 153 | let out = &ctx.out; | |
| ⋯ | |||
| 151 | 186 | for line in broken { | |
| 152 | 187 | println!(" {line}"); | |
| 153 | 188 | } | |
| 189 | + | if let Some(repo) = repo { | |
| 190 | + | println!(); | |
| 191 | + | section(out, &format!("Your sessions in {repo}"), &sessions, "None.", |session| { | |
| 192 | + | let on = [ | |
| 193 | + | session["issue"].as_u64().map(|n| format!("#{n}")), | |
| 194 | + | session["branch"].as_str().map(str::to_owned), | |
| 195 | + | ] | |
| 196 | + | .into_iter() | |
| 197 | + | .flatten() | |
| 198 | + | .collect::<Vec<_>>() | |
| 199 | + | .join(" "); | |
| 200 | + | format!("{} {} {} {}", s(session, "id"), out.state(s(session, "state")), s(session, "harness"), on) | |
| 201 | + | .trim_end() | |
| 202 | + | .to_owned() | |
| 203 | + | }); | |
| 204 | + | println!("{}", out.bold(&format!("Your claims in {repo}"))); | |
| 205 | + | if claims.is_empty() { | |
| 206 | + | println!(" {}", out.dim("None.")); | |
| 207 | + | } | |
| 208 | + | for claim in &claims { | |
| 209 | + | println!(" {}", super::work::claim_line(out, claim)); | |
| 210 | + | } | |
| 211 | + | } | |
| 154 | 212 | Ok(()) | |
| 155 | 213 | } | |
| 1 | + | //! `g1t why PATH[:LINE]`: why a file or a line is as it is. The line's | |
| 2 | + | //! commit (by blame), the pull request that merged it, the issue that pull | |
| 3 | + | //! request was for, and the decisions and sessions recorded on them. | |
| 4 | + | ||
| 5 | + | use serde_json::Value; | |
| 6 | + | ||
| 7 | + | use crate::cli::WhyArgs; | |
| 8 | + | use crate::context::Ctx; | |
| 9 | + | use crate::error::{Error, Result}; | |
| 10 | + | use crate::output::{ago, s}; | |
| 11 | + | ||
| 12 | + | /// `src/app.rs:42` as the path and the line; a path alone has no line. | |
| 13 | + | pub fn split_target(target: &str) -> (String, Option<u64>) { | |
| 14 | + | if let Some((path, line)) = target.rsplit_once(':') | |
| 15 | + | && !path.is_empty() | |
| 16 | + | && let Ok(line) = line.parse::<u64>() | |
| 17 | + | { | |
| 18 | + | return (path.to_owned(), Some(line)); | |
| 19 | + | } | |
| 20 | + | (target.to_owned(), None) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// A path given from inside the working copy as the repository's path for | |
| 24 | + | /// it: `prefix` is where the current directory is (`git rev-parse | |
| 25 | + | /// --show-prefix`), and `..` and `.` are resolved. | |
| 26 | + | pub fn repo_path(prefix: &str, given: &str) -> Result<String> { | |
| 27 | + | let given = given.replace('\\', "/"); | |
| 28 | + | let start = if given.starts_with('/') { "" } else { prefix }; | |
| 29 | + | let mut parts: Vec<&str> = Vec::new(); | |
| 30 | + | for part in start.split('/').chain(given.split('/')) { | |
| 31 | + | match part { | |
| 32 | + | "" | "." => {} | |
| 33 | + | ".." => { | |
| 34 | + | if parts.pop().is_none() { | |
| 35 | + | return Err(Error::usage(format!("{given} is outside the repository."))); | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | part => parts.push(part), | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | if parts.is_empty() { | |
| 42 | + | return Err(Error::usage("Name a file: g1t why PATH[:LINE].")); | |
| 43 | + | } | |
| 44 | + | Ok(parts.join("/")) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | pub fn run(ctx: &Ctx, args: WhyArgs) -> Result<()> { | |
| 48 | + | let (given, line) = split_target(&args.target); | |
| 49 | + | // From inside a working copy, a path is relative to where you are. | |
| 50 | + | let prefix = if ctx.global.repo.is_none() { | |
| 51 | + | crate::git::run(&["rev-parse", "--show-prefix"]).unwrap_or_default() | |
| 52 | + | } else { | |
| 53 | + | String::new() | |
| 54 | + | }; | |
| 55 | + | let path = repo_path(&prefix, &given)?; | |
| 56 | + | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 57 | + | let found = client | |
| 58 | + | .op("why") | |
| 59 | + | .repo(&repo) | |
| 60 | + | .query("path", &path) | |
| 61 | + | .query_opt("line", line) | |
| 62 | + | .query_opt("ref", args.reference.clone()) | |
| 63 | + | .send()?; | |
| 64 | + | if ctx.out.structured() { | |
| 65 | + | return ctx.out.emit(&found); | |
| 66 | + | } | |
| 67 | + | let out = &ctx.out; | |
| 68 | + | let place = match line { | |
| 69 | + | Some(line) => format!("{path}:{line}"), | |
| 70 | + | None => path.clone(), | |
| 71 | + | }; | |
| 72 | + | println!("{}", out.bold(&place)); | |
| 73 | + | let commit = &found["commit"]; | |
| 74 | + | if commit.is_object() { | |
| 75 | + | let sha = commit["sha"].as_str().or_else(|| commit["id"].as_str()).unwrap_or(""); | |
| 76 | + | let message = commit["message"].as_str().or_else(|| commit["subject"].as_str()).unwrap_or(""); | |
| 77 | + | let who = commit["author"]["name"] | |
| 78 | + | .as_str() | |
| 79 | + | .or_else(|| commit["author"]["username"].as_str()) | |
| 80 | + | .or_else(|| commit["author"].as_str()) | |
| 81 | + | .unwrap_or(""); | |
| 82 | + | let when = commit["date"] | |
| 83 | + | .as_str() | |
| 84 | + | .or_else(|| commit["author"]["date"].as_str()) | |
| 85 | + | .map(ago) | |
| 86 | + | .unwrap_or_default(); | |
| 87 | + | println!( | |
| 88 | + | " {} {} {}", | |
| 89 | + | out.dim("Commit "), | |
| 90 | + | out.yellow(sha.get(..12).unwrap_or(sha)), | |
| 91 | + | message.lines().next().unwrap_or("") | |
| 92 | + | ); | |
| 93 | + | let by = [who.to_owned(), when].into_iter().filter(|p| !p.is_empty()).collect::<Vec<_>>().join(", "); | |
| 94 | + | if !by.is_empty() { | |
| 95 | + | println!(" {}", out.dim(&by)); | |
| 96 | + | } | |
| 97 | + | } else if let Some(sha) = commit.as_str() { | |
| 98 | + | println!(" {} {}", out.dim("Commit "), out.yellow(sha.get(..12).unwrap_or(sha))); | |
| 99 | + | } | |
| 100 | + | let item = |label: &str, value: &Value| { | |
| 101 | + | if let Some(number) = value["number"].as_u64().or_else(|| value.as_u64()) { | |
| 102 | + | let status = value["status"].as_str().or_else(|| value["state"].as_str()).unwrap_or(""); | |
| 103 | + | println!(" {} #{number} {} {}", out.dim(label), s(value, "title"), out.state(status)); | |
| 104 | + | } | |
| 105 | + | }; | |
| 106 | + | item("Pull ", &found["pull"]); | |
| 107 | + | item("Issue ", &found["issue"]); | |
| 108 | + | if found["pull"].is_null() && found["issue"].is_null() { | |
| 109 | + | println!(" {}", out.dim("No pull request merged this line; it came in a push.")); | |
| 110 | + | } | |
| 111 | + | let decisions = found["decisions"].as_array().cloned().unwrap_or_default(); | |
| 112 | + | if !decisions.is_empty() { | |
| 113 | + | println!(); | |
| 114 | + | println!("{}", out.bold("Decisions")); | |
| 115 | + | for decision in &decisions { | |
| 116 | + | println!(" {}", super::work::decision_line(out, decision)); | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | let sessions = found["sessions"].as_array().cloned().unwrap_or_default(); | |
| 120 | + | if !sessions.is_empty() { | |
| 121 | + | println!(); | |
| 122 | + | println!("{}", out.bold("Sessions")); | |
| 123 | + | for session in &sessions { | |
| 124 | + | println!( | |
| 125 | + | " {} {} {}{}", | |
| 126 | + | s(session, "id"), | |
| 127 | + | super::login_of(session), | |
| 128 | + | out.dim(s(session, "harness")), | |
| 129 | + | session["title"].as_str().map(|t| format!(": {t}")).unwrap_or_default() | |
| 130 | + | ); | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | Ok(()) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[cfg(test)] | |
| 137 | + | mod tests { | |
| 138 | + | use super::*; | |
| 139 | + | ||
| 140 | + | #[test] | |
| 141 | + | fn targets_take_a_line() { | |
| 142 | + | assert_eq!(split_target("src/app.rs:42"), ("src/app.rs".into(), Some(42))); | |
| 143 | + | assert_eq!(split_target("src/app.rs"), ("src/app.rs".into(), None)); | |
| 144 | + | assert_eq!(split_target("notes:todo"), ("notes:todo".into(), None)); | |
| 145 | + | assert_eq!(split_target("C:\\code\\a.rs:3"), ("C:\\code\\a.rs".into(), Some(3))); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn paths_are_the_repositorys() { | |
| 150 | + | assert_eq!(repo_path("crates/g1t/", "src/main.rs").unwrap(), "crates/g1t/src/main.rs"); | |
| 151 | + | assert_eq!(repo_path("crates/g1t/", "../kit/src/lib.rs").unwrap(), "crates/kit/src/lib.rs"); | |
| 152 | + | assert_eq!(repo_path("", ".\\src\\app.rs").unwrap(), "src/app.rs"); | |
| 153 | + | assert_eq!(repo_path("docs/", "/README.md").unwrap(), "README.md"); | |
| 154 | + | assert!(repo_path("", "../x").is_err()); | |
| 155 | + | assert!(repo_path("", ".").is_err()); | |
| 156 | + | } | |
| 157 | + | } |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…