Skip to main content

treeship_core/session/
git.rs

1//! Git-based reconciliation for session close.
2//!
3//! Backstop layer of the trust-fabric file-capture stack:
4//!
5//!   1. (highest trust) specialized event types (`agent.wrote_file`)
6//!   2. (medium)        promoted from generic `agent.called_tool`
7//!   3. (this module)   shell out to `git` at session close and pick
8//!                      up anything an agent edited outside any
9//!                      captured tool channel
10//!
11//! Why this matters: the trust-fabric bar is "if a file changed, it
12//! must appear in the receipt." Hooks and MCP cover most paths but
13//! not all -- an agent that ran `sed -i` inside a Bash command, a
14//! build tool that modified files, or any other untracked side
15//! effect would otherwise vanish silently. Running `git diff` and
16//! `git ls-files --others` at close catches the rest.
17//!
18//! Fail-open by design: if the working dir isn't a git repo, if the
19//! git binary is missing, or if any git command errors, returns an
20//! empty list. The receipt is still produced; reconciliation is a
21//! best-effort enhancement, never a gate.
22
23use std::io::{BufRead, BufReader};
24use std::path::{Path, PathBuf};
25use std::process::{Command, Stdio};
26
27pub const DEFAULT_UNTRACKED_MAX: usize = 5_000;
28
29/// One file change observed via git that wasn't already captured by a
30/// tool channel. Mapped 1:1 into a synthetic `AgentWroteFile` event
31/// at session close so it flows through the normal aggregator and
32/// receipt composition path.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GitChange {
35    pub file_path: String,
36    /// "created", "modified", "deleted", "renamed", or "untracked".
37    pub operation: String,
38    pub additions: Option<u32>,
39    pub deletions: Option<u32>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ReconcileOptions {
44    pub untracked_max: usize,
45}
46
47impl Default for ReconcileOptions {
48    fn default() -> Self {
49        Self { untracked_max: DEFAULT_UNTRACKED_MAX }
50    }
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct ReconcileSummary {
55    pub untracked_seen: usize,
56    pub untracked_cap: usize,
57    pub untracked_truncated: bool,
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct ReconcileResult {
62    pub changes: Vec<GitChange>,
63    pub summary: ReconcileSummary,
64}
65
66/// Run `git` in `repo_dir` with the given args, returning stdout
67/// trimmed of trailing newline. Returns None on any failure (not
68/// a git repo, git missing, non-zero exit, etc.) -- this module is
69/// fail-open by contract.
70fn git_capture(repo_dir: &Path, args: &[&str]) -> Option<String> {
71    let output = Command::new("git")
72        .arg("-C").arg(repo_dir)
73        .args(args)
74        .stdin(Stdio::null())
75        .stdout(Stdio::piped())
76        .stderr(Stdio::null())
77        .output()
78        .ok()?;
79    if !output.status.success() {
80        return None;
81    }
82    let mut s = String::from_utf8(output.stdout).ok()?;
83    while s.ends_with('\n') {
84        s.pop();
85    }
86    Some(s)
87}
88
89fn git_capture_lines_limited(repo_dir: &Path, args: &[&str], limit: usize) -> Option<(Vec<String>, bool)> {
90    let mut child = Command::new("git")
91        .arg("-C").arg(repo_dir)
92        .args(args)
93        .stdin(Stdio::null())
94        .stdout(Stdio::piped())
95        .stderr(Stdio::null())
96        .spawn()
97        .ok()?;
98    let stdout = child.stdout.take()?;
99    let reader = BufReader::new(stdout);
100    let mut lines = Vec::new();
101    let mut truncated = false;
102    for line in reader.lines() {
103        let line = line.ok()?;
104        if lines.len() >= limit {
105            truncated = true;
106            let _ = child.kill();
107            break;
108        }
109        lines.push(line);
110    }
111    let status = child.wait().ok()?;
112    if !truncated && !status.success() {
113        return None;
114    }
115    Some((lines, truncated))
116}
117
118/// Fast probe: is this directory inside a git repo?
119fn is_git_repo(repo_dir: &Path) -> bool {
120    git_capture(repo_dir, &["rev-parse", "--is-inside-work-tree"])
121        .as_deref()
122        == Some("true")
123}
124
125pub fn git_toplevel(repo_dir: &Path) -> Option<PathBuf> {
126    git_capture(repo_dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
127}
128
129/// Translate a git diff/status name-status code to our operation
130/// vocabulary. Codes from `git diff --name-status`: A=added,
131/// M=modified, D=deleted, R=renamed, C=copied, T=type-change,
132/// U=unmerged, plus special "??" for ls-files untracked.
133fn translate_status(code: &str) -> &'static str {
134    match code.chars().next().unwrap_or(' ') {
135        'A' => "created",
136        'D' => "deleted",
137        'R' => "renamed",
138        'C' => "created", // copy = new file at the destination
139        'T' => "modified",
140        '?' => "untracked",
141        _   => "modified", // M, U, anything else
142    }
143}
144
145/// Parse a single line of `git diff --name-status`, returning the
146/// canonical operation and the FINAL path of the change.
147///
148/// Codex round-2 finding 5: the original `parts.next() / parts.next()`
149/// shorthand grabbed the FIRST path, which on rename / copy lines is
150/// the OLD path. So `git mv src/old.rs src/new.rs` produced
151/// `R100\told.rs\tnew.rs`, and reconcile recorded `src/old.rs` (which
152/// no longer exists) instead of `src/new.rs` (the destination the
153/// agent actually created). Cross-verify against a cert that allowed
154/// "src/new.rs" then incorrectly flagged the change as touching an
155/// unauthorized file.
156///
157/// Format from `git diff --name-status`:
158///   M\tpath               -- modified
159///   A\tpath               -- added
160///   D\tpath               -- deleted
161///   T\tpath               -- type-changed
162///   R<score>\told\tnew    -- renamed (with similarity score)
163///   C<score>\told\tnew    -- copied
164///   ?\?\tpath             -- (only from ls-files, never name-status)
165///
166/// For R* and C* we return the destination (new) path. For everything
167/// else, the single path. Returns None when the line is empty or
168/// missing fields.
169fn parse_name_status_line(line: &str) -> Option<(&'static str, String)> {
170    let mut parts = line.split('\t');
171    let code = parts.next()?;
172    if code.is_empty() {
173        return None;
174    }
175    let first_path = parts.next()?;
176    let op = translate_status(code);
177    let path = match code.chars().next().unwrap_or(' ') {
178        'R' | 'C' => {
179            // Rename / copy: the FINAL path is the third field.
180            // If the destination is missing for any reason, fall
181            // back to the source so we still record SOMETHING.
182            parts.next().map(|p| p.to_string()).unwrap_or_else(|| first_path.to_string())
183        }
184        _ => first_path.to_string(),
185    };
186    Some((op, path))
187}
188
189/// Parse a single line of `git diff --numstat` (additions, deletions,
190/// path). Numeric fields are "-" for binary files; we represent
191/// those as None.
192fn parse_numstat_line(line: &str) -> Option<(String, Option<u32>, Option<u32>)> {
193    let mut parts = line.splitn(3, '\t');
194    let adds_s = parts.next()?;
195    let dels_s = parts.next()?;
196    let path = parts.next()?.to_string();
197    let adds = adds_s.parse::<u32>().ok();
198    let dels = dels_s.parse::<u32>().ok();
199    Some((path, adds, dels))
200}
201
202/// Decides whether a path discovered by git reconciliation should be
203/// included in the receipt.
204///
205/// Treeship's own runtime artifacts -- session.closing markers,
206/// sessions/<id>/ event logs, artifact storage, scratch tmp -- live
207/// inside `.treeship/` and get touched by the very session that's
208/// closing. Without this filter they show up in `files_written` as
209/// "the agent modified .treeship/sessions/ssn_X/events.jsonl",
210/// noisy and misleading: it was Treeship's own bookkeeping, not the
211/// agent's work.
212///
213/// User-authored Treeship files (config.yaml, declaration.json, agent
214/// cards, policy) DO get surfaced -- those are the operator's own
215/// changes that an audit reader cares about.
216fn is_treeship_runtime_artifact(path: &str) -> bool {
217    // Strip leading "./" if present so both forms compare cleanly.
218    let p = path.strip_prefix("./").unwrap_or(path);
219    if !p.starts_with(".treeship/") && p != ".treeship" {
220        return false;
221    }
222    // Within .treeship/, exclude generated runtime state.
223    p == ".treeship/session.closing"
224        || p == ".treeship/session.json"
225        || p.starts_with(".treeship/sessions/")
226        || p.starts_with(".treeship/artifacts/")
227        || p.starts_with(".treeship/tmp/")
228        || p.starts_with(".treeship/proof_queue/")
229}
230
231/// Collect every file change in `repo_dir` worth surfacing in a
232/// session receipt: working-tree modifications (staged or not),
233/// committed-since-`since_sha` changes (when provided), and untracked
234/// files.
235///
236/// Deduplicated across the three sources so a single file shows up
237/// once. When `git diff --numstat` reports additions/deletions for a
238/// path, those are attached; binary files leave them as None.
239///
240/// Returns an empty Vec if `repo_dir` isn't a git repo or git isn't
241/// available -- callers (i.e. session close) should treat absence as
242/// "nothing to reconcile" and continue.
243pub fn reconcile_changes(repo_dir: &Path, since_sha: Option<&str>) -> Vec<GitChange> {
244    reconcile_changes_with_options(repo_dir, since_sha, &ReconcileOptions::default()).changes
245}
246
247pub fn reconcile_changes_with_options(
248    repo_dir: &Path,
249    since_sha: Option<&str>,
250    options: &ReconcileOptions,
251) -> ReconcileResult {
252    let mut result = ReconcileResult {
253        summary: ReconcileSummary {
254            untracked_cap: options.untracked_max,
255            ..ReconcileSummary::default()
256        },
257        ..ReconcileResult::default()
258    };
259
260    if !is_git_repo(repo_dir) {
261        return result;
262    }
263
264    use std::collections::BTreeMap;
265    // path -> change. BTreeMap so output is deterministic across runs,
266    // which matters for canonical receipt JSON / merkle stability.
267    let mut by_path: BTreeMap<String, GitChange> = BTreeMap::new();
268
269    let mut record = |path: String, op: &str| {
270        if is_treeship_runtime_artifact(&path) {
271            return;
272        }
273        by_path.entry(path.clone()).or_insert(GitChange {
274            file_path: path,
275            operation: op.to_string(),
276            additions: None,
277            deletions: None,
278        });
279    };
280
281    // 1. Uncommitted changes vs HEAD (staged + unstaged combined via
282    //    name-status). The common agent case: edited a file, didn't
283    //    commit.
284    if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--name-status"]) {
285        for line in out.lines() {
286            if let Some((op, path)) = parse_name_status_line(line) {
287                record(path, op);
288            }
289        }
290    }
291
292    // 2. Committed-during-session changes if the caller captured a
293    //    starting SHA at session start.
294    if let Some(sha) = since_sha {
295        let range = format!("{sha}..HEAD");
296        if let Some(out) = git_capture(repo_dir, &["diff", &range, "--name-status"]) {
297            for line in out.lines() {
298                if let Some((op, path)) = parse_name_status_line(line) {
299                    record(path, op);
300                }
301            }
302        }
303    }
304
305    // 3. Untracked files (new files the agent added but didn't `git
306    //    add`). These never show in `git diff` so they need their own
307    //    pass.
308    if let Some((lines, truncated)) = git_capture_lines_limited(
309        repo_dir,
310        &["ls-files", "--others", "--exclude-standard"],
311        options.untracked_max.saturating_add(1),
312    ) {
313        result.summary.untracked_seen = lines.len();
314        result.summary.untracked_truncated = truncated || lines.len() > options.untracked_max;
315        if result.summary.untracked_truncated {
316            result.summary.untracked_seen = result.summary.untracked_seen.max(options.untracked_max.saturating_add(1));
317        } else {
318            for path in lines.iter().filter(|l| !l.is_empty()) {
319                record(path.to_string(), "untracked");
320            }
321        }
322    }
323
324    // 4. Numstat for the same diff range -- attach additions/deletions
325    //    where available. Numstat reports differently than name-status
326    //    (no rename indicator), so we just match by path.
327    if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--numstat"]) {
328        for line in out.lines() {
329            if let Some((path, adds, dels)) = parse_numstat_line(line) {
330                if let Some(entry) = by_path.get_mut(&path) {
331                    entry.additions = adds;
332                    entry.deletions = dels;
333                }
334            }
335        }
336    }
337    if let Some(sha) = since_sha {
338        let range = format!("{sha}..HEAD");
339        if let Some(out) = git_capture(repo_dir, &["diff", &range, "--numstat"]) {
340            for line in out.lines() {
341                if let Some((path, adds, dels)) = parse_numstat_line(line) {
342                    if let Some(entry) = by_path.get_mut(&path) {
343                        entry.additions = adds;
344                        entry.deletions = dels;
345                    }
346                }
347            }
348        }
349    }
350
351    result.changes = by_path.into_values().collect();
352    result
353}
354
355/// Capture the current HEAD commit SHA so it can be stored in the
356/// session manifest at session start. The session close pass uses it
357/// as the diff base for committed-during-session changes. Returns
358/// None when not in a git repo or no commits exist yet.
359pub fn current_head_sha(repo_dir: &Path) -> Option<String> {
360    if !is_git_repo(repo_dir) {
361        return None;
362    }
363    git_capture(repo_dir, &["rev-parse", "HEAD"])
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn translate_status_maps_known_codes() {
372        assert_eq!(translate_status("A"), "created");
373        assert_eq!(translate_status("M"), "modified");
374        assert_eq!(translate_status("D"), "deleted");
375        assert_eq!(translate_status("R100"), "renamed");
376        assert_eq!(translate_status("??"), "untracked");
377        assert_eq!(translate_status(""), "modified");
378        assert_eq!(translate_status("X"), "modified");
379    }
380
381    #[test]
382    fn parse_numstat_handles_text_and_binary() {
383        let (p, a, d) = parse_numstat_line("12\t3\tsrc/a.rs").unwrap();
384        assert_eq!(p, "src/a.rs");
385        assert_eq!(a, Some(12));
386        assert_eq!(d, Some(3));
387
388        // Binary files: numstat uses "-\t-\tpath".
389        let (p, a, d) = parse_numstat_line("-\t-\tassets/logo.png").unwrap();
390        assert_eq!(p, "assets/logo.png");
391        assert_eq!(a, None);
392        assert_eq!(d, None);
393    }
394
395    #[test]
396    fn reconcile_in_non_git_dir_returns_empty() {
397        let tmp = std::env::temp_dir().join(format!("treeship-not-a-repo-{}", rand::random::<u32>()));
398        std::fs::create_dir_all(&tmp).unwrap();
399        let result = reconcile_changes(&tmp, None);
400        assert!(result.is_empty());
401        let _ = std::fs::remove_dir_all(&tmp);
402    }
403
404    // ── Codex round-2 finding 5: rename / copy parsing returned the
405    //    SOURCE path instead of the destination, so `git mv old new`
406    //    surfaced "old" (which no longer exists) instead of "new"
407    //    (which the agent created).
408
409    #[test]
410    fn parse_name_status_modify_uses_single_path() {
411        let (op, path) = parse_name_status_line("M\tsrc/lib.rs").unwrap();
412        assert_eq!(op, "modified");
413        assert_eq!(path, "src/lib.rs");
414    }
415
416    #[test]
417    fn parse_name_status_added_uses_single_path() {
418        let (op, path) = parse_name_status_line("A\tsrc/new.rs").unwrap();
419        assert_eq!(op, "created");
420        assert_eq!(path, "src/new.rs");
421    }
422
423    #[test]
424    fn parse_name_status_deleted_uses_single_path() {
425        let (op, path) = parse_name_status_line("D\tsrc/gone.rs").unwrap();
426        assert_eq!(op, "deleted");
427        assert_eq!(path, "src/gone.rs");
428    }
429
430    #[test]
431    fn parse_name_status_rename_uses_destination() {
432        // The bug Codex caught: this line produced path="src/old.rs"
433        // even though the agent's `git mv` ended with src/new.rs as
434        // the actual file on disk.
435        let (op, path) = parse_name_status_line("R100\tsrc/old.rs\tsrc/new.rs").unwrap();
436        assert_eq!(op, "renamed");
437        assert_eq!(path, "src/new.rs", "rename must record the destination, not the source");
438    }
439
440    #[test]
441    fn parse_name_status_copy_uses_destination() {
442        // Copies map to "created" semantically -- a new file appeared
443        // at the destination -- and we record the destination path.
444        let (op, path) = parse_name_status_line("C75\tsrc/template.rs\tsrc/new-from-template.rs").unwrap();
445        assert_eq!(op, "created");
446        assert_eq!(path, "src/new-from-template.rs", "copy must record the destination");
447    }
448
449    #[test]
450    fn parse_name_status_rename_falls_back_to_source_if_dest_missing() {
451        // Defensive: if git output were ever truncated to "R100\told"
452        // with no destination, we should still record SOMETHING
453        // rather than silently swallowing the line. Falls back to
454        // the source path -- not perfect, but visible.
455        let (op, path) = parse_name_status_line("R100\tsrc/only-old.rs").unwrap();
456        assert_eq!(op, "renamed");
457        assert_eq!(path, "src/only-old.rs");
458    }
459
460    #[test]
461    fn parse_name_status_handles_empty_or_garbage_lines() {
462        assert!(parse_name_status_line("").is_none());
463        assert!(parse_name_status_line("\t\t").is_none()); // empty code field
464        // Code with no path -- nothing to record.
465        assert!(parse_name_status_line("M").is_none());
466    }
467
468    #[test]
469    fn runtime_artifact_filter_excludes_generated_state() {
470        // Generated runtime state -- never the agent's work.
471        assert!(is_treeship_runtime_artifact(".treeship/session.closing"));
472        assert!(is_treeship_runtime_artifact(".treeship/session.json"));
473        assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/events.jsonl"));
474        assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/manifest.json"));
475        assert!(is_treeship_runtime_artifact(".treeship/artifacts/foo.json"));
476        assert!(is_treeship_runtime_artifact(".treeship/tmp/scratch"));
477        assert!(is_treeship_runtime_artifact(".treeship/proof_queue/pending.json"));
478
479        // "./"-prefixed forms (some git output emits these).
480        assert!(is_treeship_runtime_artifact("./.treeship/session.closing"));
481        assert!(is_treeship_runtime_artifact("./.treeship/sessions/ssn_x/events.jsonl"));
482    }
483
484    #[test]
485    fn runtime_artifact_filter_preserves_user_authored_files() {
486        // User-authored Treeship config / policy / cards: these ARE the
487        // operator's own changes and must show up in the receipt.
488        assert!(!is_treeship_runtime_artifact(".treeship/config.yaml"));
489        assert!(!is_treeship_runtime_artifact(".treeship/config.json"));
490        assert!(!is_treeship_runtime_artifact(".treeship/declaration.json"));
491        assert!(!is_treeship_runtime_artifact(".treeship/policy.yaml"));
492        assert!(!is_treeship_runtime_artifact(".treeship/agents/coder.agent"));
493        assert!(!is_treeship_runtime_artifact(".treeship/agents/reviewer.json"));
494
495        // Anything outside .treeship/ is never filtered.
496        assert!(!is_treeship_runtime_artifact("src/main.rs"));
497        assert!(!is_treeship_runtime_artifact("README.md"));
498        assert!(!is_treeship_runtime_artifact("treeship-notes.md"));
499        assert!(!is_treeship_runtime_artifact(".treeshiprc"));
500    }
501
502    #[test]
503    fn reconcile_filters_runtime_artifacts_end_to_end() {
504        // Build a real one-commit git repo, then make changes to a mix
505        // of runtime-artifact paths and user-authored paths. The
506        // returned reconciliation must include the user files and
507        // exclude the runtime artifacts.
508        let tmp = std::env::temp_dir().join(format!("treeship-reconcile-{}", rand::random::<u32>()));
509        std::fs::create_dir_all(&tmp).unwrap();
510
511        let run = |args: &[&str]| {
512            std::process::Command::new("git")
513                .arg("-C").arg(&tmp)
514                .args(args)
515                .stdout(std::process::Stdio::null())
516                .stderr(std::process::Stdio::null())
517                .status()
518                .ok();
519        };
520
521        run(&["init", "-q"]);
522        run(&["config", "user.email", "test@example.com"]);
523        run(&["config", "user.name", "Test"]);
524        std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
525        run(&["add", "."]);
526        run(&["commit", "-q", "-m", "init"]);
527
528        // Now create a mix: runtime artifacts and a real user file.
529        std::fs::create_dir_all(tmp.join(".treeship/sessions/ssn_x")).unwrap();
530        std::fs::create_dir_all(tmp.join(".treeship/artifacts")).unwrap();
531        std::fs::create_dir_all(tmp.join(".treeship/agents")).unwrap();
532        std::fs::write(tmp.join(".treeship/sessions/ssn_x/events.jsonl"), "{}\n").unwrap();
533        std::fs::write(tmp.join(".treeship/artifacts/foo.json"), "{}\n").unwrap();
534        std::fs::write(tmp.join(".treeship/session.closing"), "").unwrap();
535        std::fs::write(tmp.join(".treeship/agents/coder.agent"), "name: coder\n").unwrap();
536        std::fs::write(tmp.join(".treeship/declaration.json"), "{}\n").unwrap();
537        std::fs::write(tmp.join("src.rs"), "fn main() {}\n").unwrap();
538
539        let changes = reconcile_changes(&tmp, None);
540        let paths: Vec<&str> = changes.iter().map(|c| c.file_path.as_str()).collect();
541
542        // User-authored content: present.
543        assert!(paths.contains(&"src.rs"), "user file missing: {paths:?}");
544        assert!(paths.contains(&".treeship/agents/coder.agent"), "agent card missing: {paths:?}");
545        assert!(paths.contains(&".treeship/declaration.json"), "declaration missing: {paths:?}");
546
547        // Runtime artifacts: excluded.
548        assert!(!paths.contains(&".treeship/sessions/ssn_x/events.jsonl"), "leaked: {paths:?}");
549        assert!(!paths.contains(&".treeship/artifacts/foo.json"), "leaked: {paths:?}");
550        assert!(!paths.contains(&".treeship/session.closing"), "leaked: {paths:?}");
551
552        let _ = std::fs::remove_dir_all(&tmp);
553    }
554
555    #[test]
556    fn reconcile_truncates_untracked_without_promoting_per_file_events() {
557        let tmp = std::env::temp_dir().join(format!("treeship-reconcile-cap-{}", rand::random::<u32>()));
558        std::fs::create_dir_all(&tmp).unwrap();
559
560        let run = |args: &[&str]| {
561            std::process::Command::new("git")
562                .arg("-C").arg(&tmp)
563                .args(args)
564                .stdout(std::process::Stdio::null())
565                .stderr(std::process::Stdio::null())
566                .status()
567                .ok();
568        };
569
570        run(&["init", "-q"]);
571        run(&["config", "user.email", "test@example.com"]);
572        run(&["config", "user.name", "Test"]);
573        std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
574        run(&["add", "."]);
575        run(&["commit", "-q", "-m", "init"]);
576
577        std::fs::write(tmp.join("a.txt"), "a\n").unwrap();
578        std::fs::write(tmp.join("b.txt"), "b\n").unwrap();
579        std::fs::write(tmp.join("c.txt"), "c\n").unwrap();
580
581        let result = reconcile_changes_with_options(
582            &tmp,
583            None,
584            &ReconcileOptions { untracked_max: 2 },
585        );
586
587        assert!(result.summary.untracked_truncated);
588        assert_eq!(result.summary.untracked_cap, 2);
589        assert!(result.summary.untracked_seen >= 3);
590        assert!(
591            result.changes.iter().all(|c| c.operation != "untracked"),
592            "truncated untracked files must not be emitted one-per-file: {:?}",
593            result.changes,
594        );
595
596        let _ = std::fs::remove_dir_all(&tmp);
597    }
598}