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.

633 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, 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 => 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 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
184fn heartbeat(ctx: &Ctx) -> Result<()> {
185 let id = required()?;
186 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
187 let found = client.op("update_session").repo(&repo).arg("id", &id).send()?;
188 say_changes(&client, &repo, &id);
189 if ctx.out.structured() {
190 return ctx.out.emit(&session_of(found));
191 }
192 Ok(())
193}
194
195fn log(ctx: &Ctx, args: SessionLogArgs) -> Result<()> {
196 let id = required()?;
197 let text = if args.text == "-" {
198 let mut text = String::new();
199 std::io::stdin().read_to_string(&mut text)?;
200 text
201 } else {
202 args.text.clone()
203 };
204 if text.trim().is_empty() {
205 return Err(Error::usage("Nothing to record: give the text."));
206 }
207 let mut entry = json!({ "kind": args.kind.name(), "text": text.trim_end() });
208 if let Some(tool) = &args.tool {
209 entry["tool"] = json!(tool);
210 }
211 if let Some(key) = &args.key {
212 entry["key"] = json!(key);
213 }
214 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
215 let found = client
216 .op("record_session_entries")
217 .repo(&repo)
218 .arg("id", &id)
219 .field("entries", json!([entry]))
220 .send()?;
221 if ctx.out.structured() {
222 return ctx.out.emit(&found);
223 }
224 Ok(())
225}
226
227/// Where a session is: `#12`, `pull #14`, its branch.
228fn place(session: &Value) -> String {
229 let mut parts = Vec::new();
230 if let Some(item) = session["item"].as_u64() {
231 parts.push(format!("#{item}"));
232 }
233 if let Some(pull) = session["pull"].as_u64() {
234 parts.push(format!("pull #{pull}"));
235 }
236 if let Some(branch) = session["branch"].as_str().filter(|b| !b.is_empty()) {
237 parts.push(branch.to_owned());
238 }
239 parts.join(", ")
240}
241
242fn attach(ctx: &Ctx, args: SessionAttachArgs) -> Result<()> {
243 let id = required()?;
244 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
245 let found = client
246 .op("update_session")
247 .repo(&repo)
248 .arg("id", &id)
249 .field_opt("item", args.item)
250 .field_opt("pull", args.pull)
251 .field_opt("branch", args.branch.clone())
252 .field_opt("head_commit", args.head_commit.clone())
253 .field_opt("title", args.title.clone())
254 .send()?;
255 let session = session_of(found);
256 if ctx.out.structured() {
257 return ctx.out.emit(&session);
258 }
259 if args.item.is_some() || args.pull.is_some() || args.branch.is_some() || args.head_commit.is_some() {
260 eprintln!("Session {id} is on {}.", place(&session));
261 }
262 if args.title.is_some() {
263 eprintln!("Session {id} is titled {}.", s(&session, "title"));
264 }
265 Ok(())
266}
267
268fn end(ctx: &Ctx, args: SessionEndArgs) -> Result<()> {
269 let id = required()?;
270 let result = match args.result.as_deref() {
271 Some("-") => {
272 let mut text = String::new();
273 std::io::stdin().read_to_string(&mut text)?;
274 Some(text)
275 }
276 other => other.map(str::to_owned),
277 };
278 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
279 let found = client
280 .op("end_session")
281 .repo(&repo)
282 .arg("id", &id)
283 .field_opt("reason", args.reason.clone())
284 .field_opt("result", result.filter(|text| !text.trim().is_empty()))
285 .send()?;
286 forget(&id);
287 if ctx.out.structured() {
288 return ctx.out.emit(&session_of(found));
289 }
290 eprintln!("Ended session {id}.");
291 Ok(())
292}
293
294pub fn fetch_filtered(
295 client: &Client,
296 repo: &Repo,
297 state: Option<&str>,
298 item: Option<u64>,
299 pull: Option<u64>,
300 path: Option<&str>,
301 subagents: bool,
302) -> Result<Vec<Value>> {
303 let found = client
304 .op("list_sessions")
305 .repo(repo)
306 .query_opt("state", state)
307 .query_opt("item", item)
308 .query_opt("pull", pull)
309 .query_opt("path", path)
310 .query_opt("subagents", subagents.then_some("true"))
311 .send()?;
312 Ok(super::items(found, "sessions"))
313}
314
315fn list(ctx: &Ctx, args: SessionListArgs) -> Result<()> {
316 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
317 let state = args.state.map(|state| match state {
318 SessionState::Active => "active",
319 SessionState::Idle => "idle",
320 SessionState::Ended => "ended",
321 });
322 let mut sessions = fetch_filtered(&client, &repo, state, args.item, args.pull, args.path.as_deref(), args.subagents)?;
323 sessions.truncate(args.limit);
324 if ctx.out.structured() {
325 return ctx.out.emit(&Value::Array(sessions));
326 }
327 if sessions.is_empty() {
328 eprintln!("No {} sessions in {repo}.", state.unwrap_or("matching"));
329 return Ok(());
330 }
331 print_list(&ctx.out, &sessions, current().as_deref())
332}
333
334pub fn print_list(out: &Output, sessions: &[Value], mine: Option<&str>) -> Result<()> {
335 let rows: Vec<Vec<Cell>> = sessions
336 .iter()
337 .map(|session| {
338 let id = s(session, "id");
339 let marked = if Some(id) == mine { format!("{id} *") } else { id.to_owned() };
340 // A subagent is shown under what started it.
341 let marked = match session["parent_id"].as_str() {
342 Some(_) => format!("↳ {marked} {}", s(session, "agent_type")).trim_end().to_owned(),
343 None => marked,
344 };
345 vec![
346 Cell::new(marked),
347 Cell::styled(s(session, "state"), out.state(s(session, "state"))),
348 Cell::new(super::login_of(session)),
349 Cell::new(s(session, "harness")),
350 Cell::new(place(session)),
351 Cell::styled(ago(s(session, "last_seen_at")), out.dim(&ago(s(session, "last_seen_at")))),
352 ]
353 })
354 .collect();
355 out.table(&["session", "state", "who", "harness", "on", "last seen"], &rows)
356}
357
358/// What `g1t session touch` says, as `touched` items.
359fn touches(args: &SessionTouchArgs) -> Vec<Value> {
360 let mut touched = Vec::new();
361 let mut add = |kind: &str, value: String| touched.push(json!({ "kind": kind, "value": value }));
362 args.file.iter().for_each(|file| add("file", file.clone()));
363 args.commit.iter().for_each(|commit| add("commit", commit.clone()));
364 args.branch.iter().for_each(|branch| add("branch", branch.clone()));
365 args.pull.iter().for_each(|pull| add("pull", pull.to_string()));
366 args.item.iter().for_each(|item| add("item", item.to_string()));
367 touched
368}
369
370fn touch(ctx: &Ctx, args: SessionTouchArgs) -> Result<()> {
371 let id = required()?;
372 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
373 let found = client
374 .op("record_session_entries")
375 .repo(&repo)
376 .arg("id", &id)
377 .field("entries", json!([]))
378 .field("touched", Value::Array(touches(&args)))
379 .send()?;
380 if ctx.out.structured() {
381 return ctx.out.emit(&found);
382 }
383 Ok(())
384}
385
386fn updates(ctx: &Ctx, args: SessionViewArgs) -> Result<()> {
387 let id = match args.id { Some(id) => id, None => required()? };
388 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
389 // First, so an overlap the changes make is among the updates read.
390 say_changes(&client, &repo, &id);
391 let found = client.op("get_session").repo(&repo).arg("id", &id).query("after", u32::MAX).send()?;
392 // A server without inbox support must not masquerade as an empty inbox.
393 let updates = found.get("updates").and_then(Value::as_array)
394 .ok_or_else(|| Error::failed("This server does not expose session updates yet."))?;
395 if ctx.out.structured() { return ctx.out.emit(&json!({ "session_id": id, "updates": updates })); }
396 if updates.is_empty() { println!("No pending updates for {id}."); }
397 for update in updates {
398 println!("{} {} {}", s(update, "id"), s(update, "kind"), s(update, "at"));
399 println!("{}", serde_json::to_string_pretty(&update["data"]).expect("JSON values serialize"));
400 }
401 Ok(())
402}
403
404fn acknowledgement_entries(ids: &[String], note: &str) -> Value {
405 use sha2::{Digest, Sha256};
406 // Sorting makes a retry with the same IDs in another order idempotent.
407 let mut ids = ids.to_vec();
408 ids.sort();
409 ids.dedup();
410 let key = hex::encode(Sha256::digest(serde_json::to_vec(&ids).expect("strings serialize")));
411 json!([{ "kind": "note", "text": format!("Coordination updates {}: {}", ids.join(", "), note.trim()), "key": format!("ack:{key}") }])
412}
413
414fn acknowledge(ctx: &Ctx, args: SessionAcknowledgeArgs) -> Result<()> {
415 if args.note.trim().is_empty() { return Err(Error::usage("Say what you decided in --note.")); }
416 let id = match args.session { Some(id) => id, None => required()? };
417 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
418 let touched: Vec<Value> = working_changes().into_iter().map(|file| json!({ "kind": "file", "value": file })).collect();
419 let found = client.op("record_session_entries").repo(&repo).arg("id", &id)
420 .field("entries", acknowledgement_entries(&args.updates, &args.note))
421 .field("touched", Value::Array(touched))
422 .field("acknowledge", json!(args.updates)).send()?;
423 let mut given = args.updates.clone();
424 given.sort();
425 given.dedup();
426 // An older server does not count; only a count that falls short is news.
427 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);
428 if ctx.out.structured() { return ctx.out.emit(&found); }
429 println!("Recorded your decision and acknowledged the named updates for {id}.");
430 if missed > 0 {
431 eprintln!("{missed} of those IDs are not updates of {id}: check them with `g1t session updates {id}`.");
432 }
433 Ok(())
434}
435
436fn view(ctx: &Ctx, args: SessionViewArgs) -> Result<()> {
437 let id = match args.id {
438 Some(id) => id,
439 None => required()?,
440 };
441 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
442 let found = client.op("get_session").repo(&repo).arg("id", &id).send()?;
443 let mut session = session_of(found.clone());
444 if let Some(map) = session.as_object_mut() {
445 map.insert("entries".into(), found.get("entries").cloned().unwrap_or_else(|| json!([])));
446 for key in ["children", "touched", "links", "skills", "root", "updates"] {
447 if let Some(value) = found.get(key) {
448 map.insert(key.into(), value.clone());
449 }
450 }
451 }
452 if ctx.out.structured() {
453 return ctx.out.emit(&session);
454 }
455 let out = &ctx.out;
456 let title = s(&session, "title");
457 println!(
458 "{} {}",
459 out.bold(if title.is_empty() { s(&session, "id") } else { title }),
460 out.dim(&format!("{repo} {}", s(&session, "id")))
461 );
462 let mut facts = vec![out.state(s(&session, "state"))];
463 let who = super::login_of(&session);
464 if !who.is_empty() {
465 facts.push(format!("{who} with {}", s(&session, "harness")));
466 }
467 facts.push(format!("started {}", ago(s(&session, "started_at"))));
468 if let Some(seen) = session["last_seen_at"].as_str() {
469 facts.push(format!("last seen {}", ago(seen)));
470 }
471 println!("{}", facts.join(" · "));
472 let on = place(&session);
473 if !on.is_empty() {
474 println!("{} {on}", out.dim("On:"));
475 }
476 if let Some(head) = session["head_commit"].as_str() {
477 println!("{} {}", out.dim("Head:"), head.get(..12).unwrap_or(head));
478 }
479 if let Some(reason) = session["end_reason"].as_str() {
480 println!("{} {reason}", out.dim("Ended:"));
481 }
482 // A subagent: who started it, what it was asked and what it reported.
483 if let Some(parent) = session["parent_id"].as_str() {
484 let kind = session["agent_type"].as_str().map(|t| format!(" ({t})")).unwrap_or_default();
485 println!("{} {parent}{kind}", out.dim("Subagent of:"));
486 }
487 if let Some(task) = session["task"].as_str().filter(|t| !t.trim().is_empty()) {
488 println!("{} {}", out.dim("Task:"), task.trim());
489 }
490 if let Some(result) = session["result"].as_str().filter(|r| !r.trim().is_empty()) {
491 println!("{}", out.dim("Result:"));
492 for line in result.trim().lines() {
493 println!(" {line}");
494 }
495 }
496 let entries = session["entries"].as_array().cloned().unwrap_or_default();
497 if !entries.is_empty() {
498 println!();
499 }
500 let children = session["children"].as_array().cloned().unwrap_or_default();
501 if !children.is_empty() {
502 println!("{}", out.dim("Subagents:"));
503 for child in &children {
504 let depth = if child["parent_id"].as_str() == Some(s(&session, "id")) { " " } else { " " };
505 let task = child["task"].as_str().unwrap_or("");
506 println!("{depth}{} {} {} {task}", s(child, "id"), out.state(s(child, "state")), s(child, "agent_type"));
507 if let Some(result) = child["result"].as_str() {
508 println!("{depth} {}", out.dim(result.lines().next().unwrap_or("")));
509 }
510 }
511 }
512 let links = session["links"].as_array().cloned().unwrap_or_default();
513 if !links.is_empty() {
514 let named: Vec<String> = links
515 .iter()
516 .map(|link| {
517 let kind = if s(link, "kind") == "pull" { "pull " } else { "" };
518 format!("{kind}#{} {}", link["number"], s(link, "title"))
519 })
520 .collect();
521 println!("{} {}", out.dim("Worked on:"), named.join(", "));
522 }
523 let skills: Vec<String> = session["skills"].as_array().cloned().unwrap_or_default().iter().map(|skill| s(skill, "name").to_owned()).collect();
524 if !skills.is_empty() {
525 println!("{} {}", out.dim("Skills:"), skills.join(", "));
526 }
527 for entry in &entries {
528 let kind = s(entry, "kind");
529 let label = match entry["tool"].as_str() {
530 Some(tool) => format!("{kind} {tool}"),
531 None => kind.to_owned(),
532 };
533 let text = s(entry, "text").trim();
534 let first = text.lines().next().unwrap_or("");
535 println!("{} {first}", out.dim(&format!("{label:>10}")));
536 }
537 Ok(())
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn the_environment_overrides_the_kept_session() {
546 let dir = std::env::temp_dir().join(format!("g1t-session-{}", std::process::id()));
547 std::fs::create_dir_all(&dir).unwrap();
548 let path = dir.join("session");
549 std::fs::write(&path, "ses_kept\n").unwrap();
550 assert_eq!(chosen(None, Some(path.clone())).as_deref(), Some("ses_kept"));
551 assert_eq!(chosen(Some("ses_env".into()), Some(path.clone())).as_deref(), Some("ses_env"));
552 assert_eq!(chosen(Some(" ".into()), Some(path.clone())).as_deref(), Some("ses_kept"));
553 std::fs::write(&path, "\n").unwrap();
554 assert_eq!(chosen(None, Some(path)).as_deref(), None);
555 assert_eq!(chosen(None, None), None);
556 let _ = std::fs::remove_dir_all(dir);
557 }
558
559 #[test]
560 fn touches_go_as_kinds_and_values() {
561 let args = SessionTouchArgs {
562 file: vec!["src/lib.rs".into()],
563 commit: vec![],
564 branch: vec!["12-fix".into()],
565 pull: vec![14],
566 item: vec![],
567 };
568 assert_eq!(
569 Value::Array(touches(&args)),
570 json!([
571 { "kind": "file", "value": "src/lib.rs" },
572 { "kind": "branch", "value": "12-fix" },
573 { "kind": "pull", "value": "14" },
574 ])
575 );
576 }
577
578 /// UTF-8 read as Windows-1252 and saved again (the middle dot as
579 /// U+00C2 U+00B7) printed as mojibake in `g1t session view`; no source
580 /// holds it.
581 #[test]
582 fn no_source_holds_mojibake() {
583 let marks = ['\u{00C2}', '\u{00E2}'].map(|lead| {
584 // U+00C2 before a Latin-1 symbol, or U+00E2 U+20AC (how an
585 // ellipsis, a dash or a curly quote starts when read so).
586 move |text: &str| {
587 text.char_indices().any(|(i, c)| {
588 c == lead
589 && text[i + c.len_utf8()..]
590 .chars()
591 .next()
592 .is_some_and(|next| ('\u{00A0}'..='\u{00BF}').contains(&next) || next == '\u{20AC}')
593 })
594 }
595 });
596 let mut dirs = vec![std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/src"))];
597 while let Some(dir) = dirs.pop() {
598 for entry in std::fs::read_dir(dir).unwrap() {
599 let path = entry.unwrap().path();
600 if path.is_dir() {
601 dirs.push(path);
602 } else if path.extension().is_some_and(|e| e == "rs") {
603 let text = std::fs::read_to_string(&path).unwrap();
604 assert!(!marks.iter().any(|found| found(&text)), "{} holds mojibake", path.display());
605 }
606 }
607 }
608 }
609
610 #[test]
611 fn a_session_says_where_it_is() {
612 assert_eq!(place(&json!({ "item": 12, "pull": 14, "branch": "fix-x" })), "#12, pull #14, fix-x");
613 assert_eq!(place(&json!({ "item": null, "branch": "" })), "");
614 }
615 #[test]
616 fn changed_files_are_read_from_porcelain_status() {
617 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";
618 assert_eq!(changed_paths(status), ["src/a.rs", "notes/new.md", "src/new.rs", "b.rs", "gone.rs"]);
619 assert!(changed_paths("").is_empty());
620 let many: String = (0..300).map(|n| format!("?? f{n}\0")).collect();
621 assert_eq!(changed_paths(&many).len(), MAX_CHANGED);
622 }
623
624 #[test]
625 fn acknowledgement_notes_are_idempotent_for_the_same_update_set() {
626 let first = acknowledgement_entries(&["upd_b".into(), "upd_a".into()], "Scopes are separate");
627 let retry = acknowledgement_entries(&["upd_a".into(), "upd_b".into(), "upd_a".into()], "Scopes are separate");
628 assert_eq!(first, retry);
629 assert_ne!(first[0]["key"], acknowledgement_entries(&["upd_c".into()], "Scopes are separate")[0]["key"]);
630 assert!(first[0]["text"].as_str().unwrap().contains("upd_a, upd_b"));
631 }
632
633}