Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

749 linesCodeBlameRaw
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
8use serde_json::{Value, json};
9use std::io::Read;
10use std::path::PathBuf;
11
12use crate::cli::{
13 SessionAcknowledgeArgs, SessionAttachArgs, SessionCommand, SessionEndArgs, SessionHeartbeatArgs, SessionListArgs, SessionLogArgs,
14 SessionStartArgs, SessionState, SessionTouchArgs, SessionViewArgs,
15};
16use crate::client::Client;
17use crate::context::Ctx;
18use crate::error::{Error, Result};
19use crate::output::{Cell, Output, ago, s};
20use crate::repo::Repo;
21
22pub fn run(ctx: &Ctx, command: SessionCommand) -> Result<()> {
23 match command {
24 SessionCommand::Start(args) => start(ctx, args),
25 SessionCommand::Heartbeat(args) => heartbeat(ctx, args),
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 SessionCommand::Updates(args) => updates(ctx, args),
32 SessionCommand::Acknowledge(args) => acknowledge(ctx, args),
33 SessionCommand::Touch(args) => touch(ctx, args),
34 }
35}
36
37/// Where this working copy's session is kept: `<git dir>/g1t/session`.
38fn file() -> Option<PathBuf> {
39 crate::git::run(&["rev-parse", "--absolute-git-dir"])
40 .ok()
41 .filter(|dir| !dir.is_empty())
42 .map(|dir| PathBuf::from(dir).join("g1t").join("session"))
43}
44
45/// The session id in `G1T_SESSION`, else the one kept in `file`.
46fn chosen(env: Option<String>, file: Option<PathBuf>) -> Option<String> {
47 env.map(|id| id.trim().to_owned())
48 .filter(|id| !id.is_empty())
49 .or_else(|| {
50 let text = std::fs::read_to_string(file?).ok()?;
51 let id = text.lines().next()?.trim().to_owned();
52 (!id.is_empty()).then_some(id)
53 })
54}
55
56/// The current session's id, if there is one.
57pub fn current() -> Option<String> {
58 chosen(std::env::var("G1T_SESSION").ok(), file())
59}
60
61fn required() -> Result<String> {
62 current().ok_or_else(|| {
63 Error::usage("No session here: start one with `g1t session start`, or set G1T_SESSION.")
64 })
65}
66
67fn remember(id: &str) -> Result<()> {
68 let Some(path) = file() else {
69 // Outside a working copy there is nowhere to keep it; G1T_SESSION does.
70 return Ok(());
71 };
72 if let Some(dir) = path.parent() {
73 std::fs::create_dir_all(dir)?;
74 }
75 std::fs::write(&path, format!("{id}\n"))?;
76 Ok(())
77}
78
79/// Forgets the kept session, when it is `id`.
80fn forget(id: &str) {
81 if let Some(path) = file()
82 && chosen(None, Some(path.clone())).as_deref() == Some(id)
83 {
84 let _ = std::fs::remove_file(path);
85 }
86}
87
88/// The session in an answer that may wrap it (`{session, entries}`).
89fn session_of(found: Value) -> Value {
90 super::unwrap_key(found, "session")
91}
92
93fn start(ctx: &Ctx, args: SessionStartArgs) -> Result<()> {
94 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
95 let branch = args.branch.clone().or_else(crate::git::current_branch);
96 let head = crate::git::run(&["rev-parse", "HEAD"]).ok().filter(|h| !h.is_empty());
97 let found = client
98 .op("start_session")
99 .repo(&repo)
100 .field("harness", args.harness.name())
101 .field_opt("harness_session_id", args.harness_session_id.clone())
102 .field_opt("item", args.item)
103 .field_opt("pull", args.pull)
104 .field_opt("branch", branch)
105 .field_opt("title", args.title.clone())
106 .field_opt("head_commit", head)
107 .field_opt("parent_id", args.parent.clone())
108 .field_opt("agent_type", args.agent_type.clone())
109 .field_opt("task", args.task.clone())
110 .field_opt("prompt", args.prompt.clone())
111 .field_opt("model", args.model.clone())
112 .send()?;
113 let session = session_of(found);
114 let id = s(&session, "id").to_owned();
115 if id.is_empty() {
116 return Err(Error::failed("The API did not say which session it started."));
117 }
118 // A subagent's session is its harness's to keep: the working copy's stays its parent's.
119 if !args.no_remember && args.parent.is_none() {
120 remember(&id)?;
121 }
122 if ctx.out.structured() {
123 return ctx.out.emit(&session);
124 }
125 println!("{id}");
126 Ok(())
127}
128
129/// The most files said at once, as the server takes them.
130const MAX_CHANGED: usize = 200;
131
132/// The files changed in this working copy, from its root: modified, added,
133/// deleted, renamed (by the new name) and untracked, never ignored. Empty
134/// outside a working copy, or with `G1T_TOUCH=0`.
135fn working_changes() -> Vec<String> {
136 if std::env::var("G1T_TOUCH").is_ok_and(|value| value.trim() == "0") {
137 return Vec::new();
138 }
139 crate::git::run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])
140 .map(|out| changed_paths(&out))
141 .unwrap_or_default()
142}
143
144/// The paths in `git status --porcelain=v1 -z` output, at most
145/// [`MAX_CHANGED`]. A rename or copy names its new path, then its old one.
146fn changed_paths(status: &str) -> Vec<String> {
147 let mut paths = Vec::new();
148 let mut records = status.split('\0').filter(|record| !record.is_empty());
149 while let Some(record) = records.next() {
150 let Some((code, path)) = record.split_at_checked(3) else {
151 continue;
152 };
153 if code.starts_with('R') || code.starts_with('C') {
154 records.next();
155 }
156 if !path.is_empty() && !paths.iter().any(|known: &String| known == path) {
157 paths.push(path.to_owned());
158 }
159 if paths.len() == MAX_CHANGED {
160 break;
161 }
162 }
163 paths
164}
165
166/// Says what this working copy changed as touched by session `id`, so that
167/// another session changing the same files is an `overlap.detected` for
168/// both whichever harness either runs in. A file is kept once, so saying it
169/// again does nothing. Never fails the command it is part of.
170fn say_changes(client: &crate::client::Client, repo: &crate::repo::Repo, id: &str) {
171 let files: Vec<Value> = working_changes().into_iter().map(|file| json!({ "kind": "file", "value": file })).collect();
172 if files.is_empty() {
173 return;
174 }
175 let _ = client
176 .op("record_session_entries")
177 .repo(repo)
178 .arg("id", id)
179 .field("entries", json!([]))
180 .field("touched", Value::Array(files))
181 .send();
182}
183
184/// Usage as `--usage-json` gives it: a JSON list of objects, each naming
185/// its model. The server checks the counts.
186fn usage_of(text: &str) -> Result<Value> {
187 let value: Value = serde_json::from_str(text.trim())
188 .map_err(|error| Error::usage(format!("--usage-json is not JSON: {error}.")))?;
189 let named = |row: &Value| row["model"].as_str().is_some_and(|model| !model.trim().is_empty());
190 match &value {
191 Value::Array(rows) if rows.iter().all(named) => Ok(value),
192 _ => Err(Error::usage(
193 "--usage-json is a list of {model, tier, input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens}.",
194 )),
195 }
196}
197
198fn heartbeat(ctx: &Ctx, args: SessionHeartbeatArgs) -> Result<()> {
199 let id = required()?;
200 let usage = match args.usage_json.as_deref() {
201 Some("-") => {
202 let mut text = String::new();
203 std::io::stdin().read_to_string(&mut text)?;
204 Some(usage_of(&text)?)
205 }
206 Some(text) => Some(usage_of(text)?),
207 None => None,
208 };
209 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
210 let mut request = client.op("update_session").repo(&repo).arg("id", &id);
211 if let Some(usage) = usage {
212 request = request.field("usage", usage);
213 }
214 let found = request.send()?;
215 say_changes(&client, &repo, &id);
216 if ctx.out.structured() {
217 return ctx.out.emit(&session_of(found));
218 }
219 Ok(())
220}
221
222fn log(ctx: &Ctx, args: SessionLogArgs) -> Result<()> {
223 let id = required()?;
224 let text = if args.text == "-" {
225 let mut text = String::new();
226 std::io::stdin().read_to_string(&mut text)?;
227 text
228 } else {
229 args.text.clone()
230 };
231 if text.trim().is_empty() {
232 return Err(Error::usage("Nothing to record: give the text."));
233 }
234 let mut entry = json!({ "kind": args.kind.name(), "text": text.trim_end() });
235 if let Some(tool) = &args.tool {
236 entry["tool"] = json!(tool);
237 }
238 if let Some(key) = &args.key {
239 entry["key"] = json!(key);
240 }
241 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
242 let found = client
243 .op("record_session_entries")
244 .repo(&repo)
245 .arg("id", &id)
246 .field("entries", json!([entry]))
247 .send()?;
248 if ctx.out.structured() {
249 return ctx.out.emit(&found);
250 }
251 Ok(())
252}
253
254/// Where a session is: `#12`, `pull #14`, its branch.
255fn place(session: &Value) -> String {
256 let mut parts = Vec::new();
257 if let Some(item) = session["item"].as_u64() {
258 parts.push(format!("#{item}"));
259 }
260 if let Some(pull) = session["pull"].as_u64() {
261 parts.push(format!("pull #{pull}"));
262 }
263 if let Some(branch) = session["branch"].as_str().filter(|b| !b.is_empty()) {
264 parts.push(branch.to_owned());
265 }
266 parts.join(", ")
267}
268
269fn attach(ctx: &Ctx, args: SessionAttachArgs) -> Result<()> {
270 let id = required()?;
271 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
272 let found = client
273 .op("update_session")
274 .repo(&repo)
275 .arg("id", &id)
276 .field_opt("item", args.item)
277 .field_opt("pull", args.pull)
278 .field_opt("branch", args.branch.clone())
279 .field_opt("head_commit", args.head_commit.clone())
280 .field_opt("title", args.title.clone())
281 .send()?;
282 let session = session_of(found);
283 if ctx.out.structured() {
284 return ctx.out.emit(&session);
285 }
286 if args.item.is_some() || args.pull.is_some() || args.branch.is_some() || args.head_commit.is_some() {
287 eprintln!("Session {id} is on {}.", place(&session));
288 }
289 if args.title.is_some() {
290 eprintln!("Session {id} is titled {}.", s(&session, "title"));
291 }
292 Ok(())
293}
294
295fn end(ctx: &Ctx, args: SessionEndArgs) -> Result<()> {
296 let id = required()?;
297 let result = match args.result.as_deref() {
298 Some("-") => {
299 let mut text = String::new();
300 std::io::stdin().read_to_string(&mut text)?;
301 Some(text)
302 }
303 other => other.map(str::to_owned),
304 };
305 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
306 let found = client
307 .op("end_session")
308 .repo(&repo)
309 .arg("id", &id)
310 .field_opt("reason", args.reason.clone())
311 .field_opt("result", result.filter(|text| !text.trim().is_empty()))
312 .send()?;
313 forget(&id);
314 if ctx.out.structured() {
315 return ctx.out.emit(&session_of(found));
316 }
317 eprintln!("Ended session {id}.");
318 Ok(())
319}
320
321pub fn fetch_filtered(
322 client: &Client,
323 repo: &Repo,
324 state: Option<&str>,
325 item: Option<u64>,
326 pull: Option<u64>,
327 path: Option<&str>,
328 subagents: bool,
329) -> Result<Vec<Value>> {
330 let found = client
331 .op("list_sessions")
332 .repo(repo)
333 .query_opt("state", state)
334 .query_opt("item", item)
335 .query_opt("pull", pull)
336 .query_opt("path", path)
337 .query_opt("subagents", subagents.then_some("true"))
338 .send()?;
339 Ok(super::items(found, "sessions"))
340}
341
342fn list(ctx: &Ctx, args: SessionListArgs) -> Result<()> {
343 if args.workspace.is_some() {
344 return list_workspace(ctx, args);
345 }
346 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
347 let state = args.state.map(|state| match state {
348 SessionState::Active => "active",
349 SessionState::Idle => "idle",
350 SessionState::Ended => "ended",
351 });
352 let mut sessions = fetch_filtered(&client, &repo, state, args.item, args.pull, args.path.as_deref(), args.subagents)?;
353 sessions.truncate(args.limit);
354 if ctx.out.structured() {
355 return ctx.out.emit(&Value::Array(sessions));
356 }
357 if sessions.is_empty() {
358 eprintln!("No {} sessions in {repo}.", state.unwrap_or("matching"));
359 return Ok(());
360 }
361 print_list(&ctx.out, &sessions, current().as_deref())
362}
363
364/// `g1t session list --workspace`: what is cooking across a workspace.
365fn list_workspace(ctx: &Ctx, args: SessionListArgs) -> Result<()> {
366 let place = super::work::place(ctx, args.workspace.as_deref())?;
367 let state = match args.state {
368 None => "recent",
369 Some(SessionState::Active | SessionState::Idle) => "live",
370 Some(SessionState::Ended) => "ended",
371 };
372 let mut call = place.client.op("list_workspace_sessions").arg("workspace", &place.workspace).query("state", state).query("limit", args.limit.clamp(1, 200));
373 if let Some(item) = args.item {
374 call = call.query("item", item);
375 }
376 if let Some(harness) = &args.harness {
377 call = call.query("harness", harness);
378 }
379 if let Some(person) = &args.person {
380 call = call.query("person", person.trim_start_matches('@'));
381 }
382 if let Some(name) = &args.in_repo {
383 call = call.query("repo", name);
384 }
385 if args.subagents {
386 call = call.query("subagents", true);
387 }
388 let found = call.send()?;
389 if ctx.out.structured() {
390 return ctx.out.emit(&found);
391 }
392 let sessions = found["sessions"].as_array().cloned().unwrap_or_default();
393 if sessions.is_empty() {
394 eprintln!("No {} sessions in the {} workspace.", if state == "recent" { "matching" } else { state }, place.workspace);
395 return Ok(());
396 }
397 let mine = current();
398 let mut rows: Vec<Vec<Cell>> = Vec::new();
399 for session in &sessions {
400 let id = s(session, "id");
401 let marked = if Some(id) == mine.as_deref() { format!("{id} *") } else { id.to_owned() };
402 let by = match session["harness"].as_str() {
403 Some(harness) => harness.to_owned(),
404 None => format!("{} {}", s(session, "kind"), s(session, "agent")).trim().to_owned(),
405 };
406 let mut on = s(session, "repo").to_owned();
407 if let Some(item) = session["item"].as_u64() {
408 on.push_str(&format!(" #{item}"));
409 }
410 if let Some(pull) = session["pull"].as_u64() {
411 on.push_str(&format!(" PR #{pull}"));
412 }
413 rows.push(vec![
414 Cell::new(marked),
415 Cell::styled(s(session, "state"), ctx.out.state(s(session, "state"))),
416 Cell::new(s(session, "actor")),
417 Cell::new(by),
418 Cell::new(on),
419 Cell::styled(ago(s(session, "last_activity_at")), ctx.out.dim(&ago(s(session, "last_activity_at")))),
420 ]);
421 for child in session["children"].as_array().into_iter().flatten() {
422 rows.push(vec![
423 Cell::new(format!("↳ {} {}", s(child, "id"), s(child, "agent_type")).trim_end().to_owned()),
424 Cell::styled(s(child, "state"), ctx.out.state(s(child, "state"))),
425 Cell::new(super::login_of(child)),
426 Cell::new(s(child, "harness")),
427 Cell::new(String::new()),
428 Cell::styled(ago(s(child, "last_seen_at")), ctx.out.dim(&ago(s(child, "last_seen_at")))),
429 ]);
430 }
431 }
432 ctx.out.table(&["session", "state", "who", "harness", "on", "last seen"], &rows)?;
433 let waiting: u64 = found["queue"].as_array().into_iter().flatten().map(|queue| queue["active"].as_array().map_or(0, |active| active.len() as u64)).sum();
434 if waiting > 0 {
435 eprintln!("{waiting} waiting in merge queues.");
436 }
437 Ok(())
438}
439
440pub fn print_list(out: &Output, sessions: &[Value], mine: Option<&str>) -> Result<()> {
441 let rows: Vec<Vec<Cell>> = sessions
442 .iter()
443 .map(|session| {
444 let id = s(session, "id");
445 let marked = if Some(id) == mine { format!("{id} *") } else { id.to_owned() };
446 // A subagent is shown under what started it.
447 let marked = match session["parent_id"].as_str() {
448 Some(_) => format!("↳ {marked} {}", s(session, "agent_type")).trim_end().to_owned(),
449 None => marked,
450 };
451 vec![
452 Cell::new(marked),
453 Cell::styled(s(session, "state"), out.state(s(session, "state"))),
454 Cell::new(super::login_of(session)),
455 Cell::new(s(session, "harness")),
456 Cell::new(place(session)),
457 Cell::styled(ago(s(session, "last_seen_at")), out.dim(&ago(s(session, "last_seen_at")))),
458 ]
459 })
460 .collect();
461 out.table(&["session", "state", "who", "harness", "on", "last seen"], &rows)
462}
463
464/// What `g1t session touch` says, as `touched` items.
465fn touches(args: &SessionTouchArgs) -> Vec<Value> {
466 let mut touched = Vec::new();
467 let mut add = |kind: &str, value: String| touched.push(json!({ "kind": kind, "value": value }));
468 args.file.iter().for_each(|file| add("file", file.clone()));
469 args.commit.iter().for_each(|commit| add("commit", commit.clone()));
470 args.branch.iter().for_each(|branch| add("branch", branch.clone()));
471 args.pull.iter().for_each(|pull| add("pull", pull.to_string()));
472 args.item.iter().for_each(|item| add("item", item.to_string()));
473 touched
474}
475
476fn touch(ctx: &Ctx, args: SessionTouchArgs) -> Result<()> {
477 let id = required()?;
478 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
479 let found = client
480 .op("record_session_entries")
481 .repo(&repo)
482 .arg("id", &id)
483 .field("entries", json!([]))
484 .field("touched", Value::Array(touches(&args)))
485 .send()?;
486 if ctx.out.structured() {
487 return ctx.out.emit(&found);
488 }
489 Ok(())
490}
491
492fn updates(ctx: &Ctx, args: SessionViewArgs) -> Result<()> {
493 let id = match args.id { Some(id) => id, None => required()? };
494 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
495 // First, so an overlap the changes make is among the updates read.
496 say_changes(&client, &repo, &id);
497 let found = client.op("get_session").repo(&repo).arg("id", &id).query("after", u32::MAX).send()?;
498 // A server without inbox support must not masquerade as an empty inbox.
499 let updates = found.get("updates").and_then(Value::as_array)
500 .ok_or_else(|| Error::failed("This server does not expose session updates yet."))?;
501 if ctx.out.structured() { return ctx.out.emit(&json!({ "session_id": id, "updates": updates })); }
502 if updates.is_empty() { println!("No pending updates for {id}."); }
503 for update in updates {
504 println!("{} {} {}", s(update, "id"), s(update, "kind"), s(update, "at"));
505 println!("{}", serde_json::to_string_pretty(&update["data"]).expect("JSON values serialize"));
506 }
507 Ok(())
508}
509
510fn acknowledgement_entries(ids: &[String], note: &str) -> Value {
511 use sha2::{Digest, Sha256};
512 // Sorting makes a retry with the same IDs in another order idempotent.
513 let mut ids = ids.to_vec();
514 ids.sort();
515 ids.dedup();
516 let key = hex::encode(Sha256::digest(serde_json::to_vec(&ids).expect("strings serialize")));
517 json!([{ "kind": "note", "text": format!("Coordination updates {}: {}", ids.join(", "), note.trim()), "key": format!("ack:{key}") }])
518}
519
520fn acknowledge(ctx: &Ctx, args: SessionAcknowledgeArgs) -> Result<()> {
521 if args.note.trim().is_empty() { return Err(Error::usage("Say what you decided in --note.")); }
522 let id = match args.session { Some(id) => id, None => required()? };
523 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
524 let touched: Vec<Value> = working_changes().into_iter().map(|file| json!({ "kind": "file", "value": file })).collect();
525 let found = client.op("record_session_entries").repo(&repo).arg("id", &id)
526 .field("entries", acknowledgement_entries(&args.updates, &args.note))
527 .field("touched", Value::Array(touched))
528 .field("acknowledge", json!(args.updates)).send()?;
529 let mut given = args.updates.clone();
530 given.sort();
531 given.dedup();
532 // An older server does not count; only a count that falls short is news.
533 let missed = found.get("acknowledged").and_then(Value::as_u64).map(|n| given.len() as u64 - n.min(given.len() as u64)).unwrap_or(0);
534 if ctx.out.structured() { return ctx.out.emit(&found); }
535 println!("Recorded your decision and acknowledged the named updates for {id}.");
536 if missed > 0 {
537 eprintln!("{missed} of those IDs are not updates of {id}: check them with `g1t session updates {id}`.");
538 }
539 Ok(())
540}
541
542fn view(ctx: &Ctx, args: SessionViewArgs) -> Result<()> {
543 let id = match args.id {
544 Some(id) => id,
545 None => required()?,
546 };
547 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
548 let found = client.op("get_session").repo(&repo).arg("id", &id).send()?;
549 let mut session = session_of(found.clone());
550 if let Some(map) = session.as_object_mut() {
551 map.insert("entries".into(), found.get("entries").cloned().unwrap_or_else(|| json!([])));
552 for key in ["children", "touched", "links", "skills", "root", "updates"] {
553 if let Some(value) = found.get(key) {
554 map.insert(key.into(), value.clone());
555 }
556 }
557 }
558 if ctx.out.structured() {
559 return ctx.out.emit(&session);
560 }
561 let out = &ctx.out;
562 let title = s(&session, "title");
563 println!(
564 "{} {}",
565 out.bold(if title.is_empty() { s(&session, "id") } else { title }),
566 out.dim(&format!("{repo} {}", s(&session, "id")))
567 );
568 let mut facts = vec![out.state(s(&session, "state"))];
569 let who = super::login_of(&session);
570 if !who.is_empty() {
571 facts.push(format!("{who} with {}", s(&session, "harness")));
572 }
573 facts.push(format!("started {}", ago(s(&session, "started_at"))));
574 if let Some(seen) = session["last_seen_at"].as_str() {
575 facts.push(format!("last seen {}", ago(seen)));
576 }
577 println!("{}", facts.join(" · "));
578 let on = place(&session);
579 if !on.is_empty() {
580 println!("{} {on}", out.dim("On:"));
581 }
582 if let Some(head) = session["head_commit"].as_str() {
583 println!("{} {}", out.dim("Head:"), head.get(..12).unwrap_or(head));
584 }
585 if let Some(reason) = session["end_reason"].as_str() {
586 println!("{} {reason}", out.dim("Ended:"));
587 }
588 // A subagent: who started it, what it was asked and what it reported.
589 if let Some(parent) = session["parent_id"].as_str() {
590 let kind = session["agent_type"].as_str().map(|t| format!(" ({t})")).unwrap_or_default();
591 println!("{} {parent}{kind}", out.dim("Subagent of:"));
592 }
593 if let Some(task) = session["task"].as_str().filter(|t| !t.trim().is_empty()) {
594 println!("{} {}", out.dim("Task:"), task.trim());
595 }
596 if let Some(result) = session["result"].as_str().filter(|r| !r.trim().is_empty()) {
597 println!("{}", out.dim("Result:"));
598 for line in result.trim().lines() {
599 println!(" {line}");
600 }
601 }
602 let entries = session["entries"].as_array().cloned().unwrap_or_default();
603 if !entries.is_empty() {
604 println!();
605 }
606 let children = session["children"].as_array().cloned().unwrap_or_default();
607 if !children.is_empty() {
608 println!("{}", out.dim("Subagents:"));
609 for child in &children {
610 let depth = if child["parent_id"].as_str() == Some(s(&session, "id")) { " " } else { " " };
611 let task = child["task"].as_str().unwrap_or("");
612 println!("{depth}{} {} {} {task}", s(child, "id"), out.state(s(child, "state")), s(child, "agent_type"));
613 if let Some(result) = child["result"].as_str() {
614 println!("{depth} {}", out.dim(result.lines().next().unwrap_or("")));
615 }
616 }
617 }
618 let links = session["links"].as_array().cloned().unwrap_or_default();
619 if !links.is_empty() {
620 let named: Vec<String> = links
621 .iter()
622 .map(|link| {
623 let kind = if s(link, "kind") == "pull" { "pull " } else { "" };
624 format!("{kind}#{} {}", link["number"], s(link, "title"))
625 })
626 .collect();
627 println!("{} {}", out.dim("Worked on:"), named.join(", "));
628 }
629 let skills: Vec<String> = session["skills"].as_array().cloned().unwrap_or_default().iter().map(|skill| s(skill, "name").to_owned()).collect();
630 if !skills.is_empty() {
631 println!("{} {}", out.dim("Skills:"), skills.join(", "));
632 }
633 for entry in &entries {
634 let kind = s(entry, "kind");
635 let label = match entry["tool"].as_str() {
636 Some(tool) => format!("{kind} {tool}"),
637 None => kind.to_owned(),
638 };
639 let text = s(entry, "text").trim();
640 let first = text.lines().next().unwrap_or("");
641 println!("{} {first}", out.dim(&format!("{label:>10}")));
642 }
643 Ok(())
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
651 fn usage_is_a_list_of_models_and_their_counts() {
652 let usage = usage_of(r#" [{"model":"claude-opus-5-5","input_tokens":3}] "#).unwrap();
653 assert_eq!(usage[0]["input_tokens"], 3);
654 assert!(usage_of("[]").is_ok());
655 assert!(usage_of("{}").is_err());
656 assert!(usage_of(r#"[{"input_tokens":3}]"#).is_err());
657 assert!(usage_of("not json").is_err());
658 }
659
660 #[test]
661 fn the_environment_overrides_the_kept_session() {
662 let dir = std::env::temp_dir().join(format!("g1t-session-{}", std::process::id()));
663 std::fs::create_dir_all(&dir).unwrap();
664 let path = dir.join("session");
665 std::fs::write(&path, "ses_kept\n").unwrap();
666 assert_eq!(chosen(None, Some(path.clone())).as_deref(), Some("ses_kept"));
667 assert_eq!(chosen(Some("ses_env".into()), Some(path.clone())).as_deref(), Some("ses_env"));
668 assert_eq!(chosen(Some(" ".into()), Some(path.clone())).as_deref(), Some("ses_kept"));
669 std::fs::write(&path, "\n").unwrap();
670 assert_eq!(chosen(None, Some(path)).as_deref(), None);
671 assert_eq!(chosen(None, None), None);
672 let _ = std::fs::remove_dir_all(dir);
673 }
674
675 #[test]
676 fn touches_go_as_kinds_and_values() {
677 let args = SessionTouchArgs {
678 file: vec!["src/lib.rs".into()],
679 commit: vec![],
680 branch: vec!["12-fix".into()],
681 pull: vec![14],
682 item: vec![],
683 };
684 assert_eq!(
685 Value::Array(touches(&args)),
686 json!([
687 { "kind": "file", "value": "src/lib.rs" },
688 { "kind": "branch", "value": "12-fix" },
689 { "kind": "pull", "value": "14" },
690 ])
691 );
692 }
693
694 /// UTF-8 read as Windows-1252 and saved again (the middle dot as
695 /// U+00C2 U+00B7) printed as mojibake in `g1t session view`; no source
696 /// holds it.
697 #[test]
698 fn no_source_holds_mojibake() {
699 let marks = ['\u{00C2}', '\u{00E2}'].map(|lead| {
700 // U+00C2 before a Latin-1 symbol, or U+00E2 U+20AC (how an
701 // ellipsis, a dash or a curly quote starts when read so).
702 move |text: &str| {
703 text.char_indices().any(|(i, c)| {
704 c == lead
705 && text[i + c.len_utf8()..]
706 .chars()
707 .next()
708 .is_some_and(|next| ('\u{00A0}'..='\u{00BF}').contains(&next) || next == '\u{20AC}')
709 })
710 }
711 });
712 let mut dirs = vec![std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/src"))];
713 while let Some(dir) = dirs.pop() {
714 for entry in std::fs::read_dir(dir).unwrap() {
715 let path = entry.unwrap().path();
716 if path.is_dir() {
717 dirs.push(path);
718 } else if path.extension().is_some_and(|e| e == "rs") {
719 let text = std::fs::read_to_string(&path).unwrap();
720 assert!(!marks.iter().any(|found| found(&text)), "{} holds mojibake", path.display());
721 }
722 }
723 }
724 }
725
726 #[test]
727 fn a_session_says_where_it_is() {
728 assert_eq!(place(&json!({ "item": 12, "pull": 14, "branch": "fix-x" })), "#12, pull #14, fix-x");
729 assert_eq!(place(&json!({ "item": null, "branch": "" })), "");
730 }
731 #[test]
732 fn changed_files_are_read_from_porcelain_status() {
733 let status = " M src/a.rs\0?? notes/new.md\0R src/new.rs\0src/old.rs\0R b.rs\0a\0D gone.rs\0 M src/a.rs\0";
734 assert_eq!(changed_paths(status), ["src/a.rs", "notes/new.md", "src/new.rs", "b.rs", "gone.rs"]);
735 assert!(changed_paths("").is_empty());
736 let many: String = (0..300).map(|n| format!("?? f{n}\0")).collect();
737 assert_eq!(changed_paths(&many).len(), MAX_CHANGED);
738 }
739
740 #[test]
741 fn acknowledgement_notes_are_idempotent_for_the_same_update_set() {
742 let first = acknowledgement_entries(&["upd_b".into(), "upd_a".into()], "Scopes are separate");
743 let retry = acknowledgement_entries(&["upd_a".into(), "upd_b".into(), "upd_a".into()], "Scopes are separate");
744 assert_eq!(first, retry);
745 assert_ne!(first[0]["key"], acknowledgement_entries(&["upd_c".into()], "Scopes are separate")[0]["key"]);
746 assert!(first[0]["text"].as_str().unwrap().contains("upd_a, upd_b"));
747 }
748
749}