Skip to content

Commit

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.

syntaqxcommitted Parente8c2c47Browse files
14 files+2369−110/14 viewed
+117−0
281281
282282 Your open pull requests and their checks, reviews asked of you, what is
283283 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.
284401
285402 ## Scripting with JSON
286403
+265−2
735735
736736 ## g1t status
737737
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.
739739
740740 ```text
741741 g1t status [OPTIONS]
742742 ```
743743
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/).
7451008
7461009 ## g1t api
7471010
+277−1
9999 Deploy(DeployCommand),
100100 /// A project's logs and errors.
101101 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.
103103 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),
104112 /// Call any API operation or path, with your token.
105113 Api(ApiArgs),
106114 /// Open the repository, an issue or a pull request in the browser.
781789 pub limit: usize,
782790 }
783791
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+
7841060 // --- status, api, browse ---------------------------------------------
7851061
7861062 #[derive(Debug, Args)]
+30−0
7171 }
7272 }
7373
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+
7487 pub fn has_token(&self) -> bool {
7588 self.token.is_some()
7689 }
175188 ) -> Result<Value> {
176189 let bytes = body.map(|b| serde_json::to_vec(b).expect("json"));
177190 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+ }
178198 if !(200..300).contains(&reply.status) {
179199 return Err(self.api_error(&reply));
180200 }
231251 }
232252 }
233253
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+
234264 /// One call to an operation, its path filled in as it is built.
235265 pub struct Call<'a> {
236266 client: &'a Client,
+1−2
298298 fn list_ops(ctx: &Ctx, query: &str) -> Result<()> {
299299 let q = query.to_lowercase();
300300 let mapped = crate::mapping::by_operation();
301− let found: Vec<&Operation> = ops::OPERATIONS
302− .iter()
301+ let found: Vec<&Operation> = ops::all()
303302 .filter(|op| {
304303 q.is_empty()
305304 || op.id.contains(&q)
+49−0
1111 pub mod repo;
1212 pub mod run;
1313 pub mod secret;
14+pub mod session;
1415 pub mod status;
16+pub mod why;
17+pub mod work;
1518
1619 use clap::CommandFactory;
1720
4043 Command::Deploy(command) => deploy::run(&Ctx::new(cli.global)?, command),
4144 Command::Logs(args) => logs::run(&Ctx::new(cli.global)?, args),
4245 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),
4349 Command::Api(args) => api::run(&Ctx::new(cli.global)?, args),
4450 Command::Browse(args) => browse::run(&Ctx::new(cli.global)?, args),
4551 }
5460 }
5561 }
5662
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+
57106 /// The items of a list answer, which comes bare or under a key.
58107 pub fn items(value: serde_json::Value, key: &str) -> Vec<serde_json::Value> {
59108 match value {
+353−0
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+}
+60−2
5858 })
5959 }
6060
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+
6191 fn section(
6292 out: &Output,
6393 title: &str,
78108 pub fn run(ctx: &Ctx) -> Result<()> {
79109 let client = ctx.signed_in_client()?;
80110 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| {
82112 let pulls = scope.spawn(|| search_pulls(&client, &me).map(|p| with_checks(&client, p)));
83113 let reviews = scope.spawn(|| notifications(&client, "review_requested"));
84114 let assigned = scope.spawn(|| notifications(&client, "assign"));
85115 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())
87118 });
119+ let (repo, sessions, claims) = here.unwrap_or((None, Vec::new(), Vec::new()));
88120 let unwrap = |r: std::thread::Result<Result<Vec<Value>>>| -> Result<Vec<Value>> {
89121 r.unwrap_or_else(|_| Err(crate::error::Error::failed("A lookup failed.")))
90122 };
113145 "assigned": assigned,
114146 "failing_checks": failing,
115147 "ci_failures": ci,
148+ "repo": repo,
149+ "sessions": sessions,
150+ "claims": claims,
116151 }));
117152 }
118153 let out = &ctx.out;
151186 for line in broken {
152187 println!(" {line}");
153188 }
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+ }
154212 Ok(())
155213 }
+157−0
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+}
+765−0
Loading the diff…
+27−3
Loading the diff…
+66−1
Loading the diff…
+19−0
Loading the diff…
+183−0
Loading the diff…