返回 CodeWhale
git_status.rs
根目录 / crates / tui / src / tui / git_status.rs
1 //! Native git status / worktree surface for the TUI chrome.
2 //!
3 //! Cached and non-blocking: probes run off the render path on a background
4 //! thread and the renderer only ever reads [`cached_status`].
5 //!
6 //! A probe shells out to the real `git` binary. One
7 //! `status --porcelain=v2 --branch -z` carries the branch, its upstream,
8 //! ahead/behind and every changed path, replacing the separate
9 //! `symbolic-ref`, `rev-list` and `status --porcelain` calls it used to make
10 //! (#6565). Around it: `rev-parse --show-toplevel`, one
11 //! `rev-parse --git-dir --git-common-dir` (repository name and linked
12 //! worktree), `log -5` for recent commits, `worktree list --porcelain`, and
13 //! `remote get-url origin` by way of
14 //! [`crate::remote_control::observed_git_repo`]. Git older than 2.11 has no
15 //! porcelain v2; the probe then falls back to the old three calls. There is
16 //! no `gix` dependency and no per-invocation timeout. All of these run with
17 //! `GIT_OPTIONAL_LOCKS=0` so a read never contends for `.git/index.lock` in
18 //! the user's repository.
19 //!
20 //! The same status call and parser back the composer's "branch | status"
21 //! badge ([`context_line`]) and the engine's per-turn git line
22 //! ([`probe_workspace_status`]), so the chrome, the Git view and the model
23 //! read one parser instead of three.
24 //!
25 //! This module owns capability and state outside the renderer so
26 //! `widgets/mod.rs` / `ui.rs` stay projection-only.
27
28 #![allow(dead_code)] // Public API; worktree manager wiring continues post-render polish.
29
30 use crate::dependencies::{ExternalTool, Git};
31 use std::path::{Path, PathBuf};
32 use std::sync::atomic::{AtomicBool, Ordering};
33 use std::sync::{Mutex, OnceLock};
34 use std::time::{Duration, Instant};
35
36 /// Snapshot of repository status for chrome / worktree manager.
37 #[derive(Debug, Clone, Default, PartialEq, Eq)]
38 pub struct GitStatusSnapshot {
39 pub root: Option<PathBuf>,
40 pub repository_name: Option<String>,
41 pub branch: Option<String>,
42 /// `owner/name` when `origin` resolves to a recognised forge, from the
43 /// one normalizer that owns that judgement
44 /// ([`crate::remote_control::normalize_observed_git_repo`]): paths,
45 /// credentials, and unknown hosts are dropped rather than displayed.
46 /// Cached here so chrome can name the repository without probing on the
47 /// render path.
48 pub remote_slug: Option<String>,
49 pub dirty: bool,
50 pub ahead: u32,
51 pub behind: u32,
52 /// Whether the branch tracks an upstream; `ahead`/`behind` mean nothing
53 /// without one.
54 pub has_upstream: bool,
55 /// `branch` holds a short commit id because HEAD is detached.
56 pub detached: bool,
57 pub changes: ChangeCounts,
58 /// Total changed paths before the display list is capped.
59 pub changed_path_count: usize,
60 /// The first [`MAX_CHANGED_PATHS`] changed paths, in git's order.
61 pub changed_paths: Vec<ChangedPath>,
62 pub recent_commits: Vec<RecentCommit>,
63 /// This checkout is a linked worktree, not the main one.
64 pub is_linked_worktree: bool,
65 pub worktrees: Vec<WorktreeEntry>,
66 pub fetched_at: Option<Instant>,
67 pub error: Option<String>,
68 /// The workspace this snapshot was probed *from*, which is not the same
69 /// as [`Self::root`]: launching in a subdirectory gives a `root` of the
70 /// repository top level while the workspace stays the subdirectory.
71 /// Staleness must compare the probe's own input, not its result.
72 pub probed_workspace: Option<PathBuf>,
73 }
74
75 /// Changed paths by kind, classified exactly as the composer badge always
76 /// did: a path can be both staged and modified; `?` is untracked; `U` is a
77 /// conflict.
78 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
79 pub struct ChangeCounts {
80 pub staged: usize,
81 pub modified: usize,
82 pub untracked: usize,
83 pub conflicts: usize,
84 }
85
86 impl ChangeCounts {
87 #[must_use]
88 pub fn is_clean(&self) -> bool {
89 *self == Self::default()
90 }
91
92 /// `2 staged, 1 modified`, or `clean`.
93 #[must_use]
94 pub fn summary(&self) -> String {
95 let parts = [
96 (self.staged, "staged"),
97 (self.modified, "modified"),
98 (self.untracked, "untracked"),
99 (self.conflicts, "conflicts"),
100 ]
101 .into_iter()
102 .filter(|(count, _)| *count > 0)
103 .map(|(count, word)| format!("{count} {word}"))
104 .collect::<Vec<_>>();
105 if parts.is_empty() {
106 "clean".to_string()
107 } else {
108 parts.join(", ")
109 }
110 }
111
112 fn record(&mut self, x: char, y: char, unmerged: bool) {
113 if x == '?' && y == '?' {
114 self.untracked = self.untracked.saturating_add(1);
115 return;
116 }
117 // An unmerged path is a conflict and nothing else: its two letters
118 // name the merge sides, not a staged and a worktree change (U08-m2).
119 if unmerged {
120 self.conflicts = self.conflicts.saturating_add(1);
121 return;
122 }
123 if x != ' ' && x != '?' {
124 self.staged = self.staged.saturating_add(1);
125 }
126 if y != ' ' && y != '?' {
127 self.modified = self.modified.saturating_add(1);
128 }
129 }
130 }
131
132 /// One changed path with its two-letter status (`M `, ` M`, `??`, `UU`).
133 #[derive(Debug, Clone, PartialEq, Eq)]
134 pub struct ChangedPath {
135 pub code: String,
136 pub path: String,
137 }
138
139 #[derive(Debug, Clone, PartialEq, Eq)]
140 pub struct RecentCommit {
141 pub hash: String,
142 pub subject: String,
143 /// Relative commit time as git words it (`3 hours ago`).
144 pub when: String,
145 }
146
147 /// Changed paths kept for the Git view.
148 pub const MAX_CHANGED_PATHS: usize = 20;
149 /// Recent commits kept for the Git view.
150 const RECENT_COMMITS: &str = "-5";
151
152 /// What one `git status --porcelain=v2 --branch -z` says.
153 #[derive(Debug, Clone, Default, PartialEq, Eq)]
154 pub struct PorcelainStatus {
155 /// Branch name, or `None` for a detached HEAD.
156 pub head: Option<String>,
157 /// Commit id; `None` on an unborn branch.
158 pub oid: Option<String>,
159 pub upstream: Option<String>,
160 pub ahead: u32,
161 pub behind: u32,
162 pub changes: ChangeCounts,
163 pub changed_path_count: usize,
164 pub changed_paths: Vec<ChangedPath>,
165 }
166
167 impl PorcelainStatus {
168 /// The ref the badge shows: the branch, or `detached:<short id>`.
169 #[must_use]
170 pub fn branch_label(&self) -> Option<String> {
171 match (&self.head, &self.oid) {
172 (Some(head), _) => Some(head.clone()),
173 (None, Some(oid)) => Some(format!("detached:{}", short_oid(oid))),
174 (None, None) => None,
175 }
176 }
177 }
178
179 fn short_oid(oid: &str) -> &str {
180 oid.get(..7).unwrap_or(oid)
181 }
182
183 /// Parse `git status --porcelain=v2 --branch -z`. `None` when the output has
184 /// no `# branch.` header (a git too old for porcelain v2), so the caller can
185 /// fall back.
186 #[must_use]
187 pub fn parse_porcelain_v2(raw: &str) -> Option<PorcelainStatus> {
188 let mut status = PorcelainStatus::default();
189 let mut saw_branch_header = false;
190 let mut records = raw.split('\0').filter(|record| !record.is_empty());
191 while let Some(record) = records.next() {
192 if let Some(header) = record.strip_prefix("# branch.") {
193 saw_branch_header = true;
194 let (key, value) = header.split_once(' ').unwrap_or((header, ""));
195 match key {
196 "oid" if value != "(initial)" => status.oid = Some(value.to_string()),
197 "head" if value != "(detached)" => status.head = Some(value.to_string()),
198 "upstream" => status.upstream = Some(value.to_string()),
199 "ab" => {
200 for part in value.split_whitespace() {
201 if let Some(ahead) = part.strip_prefix('+') {
202 status.ahead = ahead.parse().unwrap_or(0);
203 } else if let Some(behind) = part.strip_prefix('-') {
204 status.behind = behind.parse().unwrap_or(0);
205 }
206 }
207 }
208 _ => {}
209 }
210 continue;
211 }
212 let (kind, rest) = record.split_at(record.len().min(2));
213 let (code, path) = match kind {
214 // `1 XY sub mH mI mW hH hI path`
215 "1 " => (rest.get(..2), rest.splitn(8, ' ').nth(7)),
216 // `2 XY sub mH mI mW hH hI Xscore path`, then the original path.
217 "2 " => {
218 let _original = records.next();
219 (rest.get(..2), rest.splitn(9, ' ').nth(8))
220 }
221 // `u XY sub m1 m2 m3 mW h1 h2 h3 path`
222 "u " => (rest.get(..2), rest.splitn(10, ' ').nth(9)),
223 "? " => (Some("??"), Some(rest)),
224 _ => continue,
225 };
226 let (Some(code), Some(path)) = (code, path) else {
227 continue;
228 };
229 let mut letters = code.chars().map(|c| if c == '.' { ' ' } else { c });
230 let x = letters.next().unwrap_or(' ');
231 let y = letters.next().unwrap_or(' ');
232 status.changes.record(x, y, kind == "u ");
233 status.changed_path_count += 1;
234 if status.changed_paths.len() < MAX_CHANGED_PATHS {
235 status.changed_paths.push(ChangedPath {
236 code: format!("{x}{y}"),
237 path: path.to_string(),
238 });
239 }
240 }
241 saw_branch_header.then_some(status)
242 }
243
244 /// Porcelain v1 (`git status --porcelain`) for git older than 2.11: counts
245 /// and paths only; the branch comes from separate calls.
246 fn parse_porcelain_v1(raw: &str) -> (ChangeCounts, Vec<ChangedPath>, usize) {
247 let mut counts = ChangeCounts::default();
248 let mut paths = Vec::new();
249 let mut path_count = 0;
250 for line in raw.lines() {
251 let mut chars = line.chars();
252 let (Some(x), Some(y)) = (chars.next(), chars.next()) else {
253 continue;
254 };
255 if x == ' ' && y == ' ' {
256 continue;
257 }
258 counts.record(
259 x,
260 y,
261 x == 'U' || y == 'U' || matches!((x, y), ('A', 'A') | ('D', 'D')),
262 );
263 path_count += 1;
264 if paths.len() < MAX_CHANGED_PATHS {
265 paths.push(ChangedPath {
266 code: format!("{x}{y}"),
267 path: line.get(3..).unwrap_or_default().to_string(),
268 });
269 }
270 }
271 (counts, paths, path_count)
272 }
273
274 /// Branch, upstream and changes for `workspace` from one git call (two on a
275 /// git without porcelain v2). Errors outside a repository or when status fails.
276 /// This is the whole cost of the engine's per-turn git line.
277 ///
278 /// # Errors
279 /// Returns the Git diagnostic if status cannot be read.
280 pub fn probe_workspace_status(workspace: &Path) -> Result<PorcelainStatus, String> {
281 crate::project_context::find_git_root(workspace).ok_or("not a git repository")?;
282 let raw = git_output(
283 workspace,
284 &[
285 "status",
286 "--porcelain=v2",
287 "--branch",
288 "-z",
289 "--untracked-files=normal",
290 "--ignore-submodules=dirty",
291 ],
292 )
293 .ok();
294 if let Some(status) = raw.as_deref().and_then(parse_porcelain_v2) {
295 return Ok(status);
296 }
297 legacy_workspace_status(workspace)
298 }
299
300 /// The pre-2.11 path: the three calls porcelain v2 replaced.
301 fn legacy_workspace_status(workspace: &Path) -> Result<PorcelainStatus, String> {
302 let raw = git_output(
303 workspace,
304 &[
305 "status",
306 "--porcelain",
307 "--untracked-files=normal",
308 "--ignore-submodules=dirty",
309 ],
310 )?;
311 let (changes, changed_paths, changed_path_count) = parse_porcelain_v1(&raw);
312 let head = git_output(workspace, &["symbolic-ref", "--short", "HEAD"])
313 .ok()
314 .map(|s| s.trim().to_string())
315 .filter(|s| !s.is_empty());
316 let oid = git_output(workspace, &["rev-parse", "HEAD"])
317 .ok()
318 .map(|s| s.trim().to_string())
319 .filter(|s| !s.is_empty());
320 let mut status = PorcelainStatus {
321 head,
322 oid,
323 changes,
324 changed_paths,
325 changed_path_count,
326 ..PorcelainStatus::default()
327 };
328 if let Ok(counts) = git_output(
329 workspace,
330 &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
331 ) {
332 let mut parts = counts.split_whitespace();
333 if let (Some(behind), Some(ahead)) = (parts.next(), parts.next()) {
334 status.behind = behind.parse().unwrap_or(0);
335 status.ahead = ahead.parse().unwrap_or(0);
336 status.upstream = Some("@{upstream}".to_string());
337 }
338 }
339 Ok(status)
340 }
341
342 /// `branch | 2 staged, 1 modified` — the composer badge and the engine's
343 /// per-turn git line, from a status probe.
344 #[must_use]
345 pub fn status_line(status: &PorcelainStatus) -> Option<String> {
346 Some(format!(
347 "{} | {}",
348 status.branch_label()?,
349 status.changes.summary()
350 ))
351 }
352
353 /// [`status_line`] from a cached snapshot. `None` when the snapshot has not
354 /// found a repository.
355 #[must_use]
356 pub fn context_line(snap: &GitStatusSnapshot) -> Option<String> {
357 snap.root.as_ref()?;
358 if let Some(error) = &snap.error {
359 return Some(error.clone());
360 }
361 let branch = snap.branch.as_deref()?;
362 let branch = if snap.detached {
363 format!("detached:{branch}")
364 } else {
365 branch.to_string()
366 };
367 Some(format!("{branch} | {}", snap.changes.summary()))
368 }
369
370 fn parse_recent_commits(raw: &str) -> Vec<RecentCommit> {
371 raw.lines()
372 .filter_map(|line| {
373 let mut parts = line.splitn(3, '\u{1f}');
374 Some(RecentCommit {
375 hash: parts.next()?.trim().to_string(),
376 subject: parts.next()?.trim().to_string(),
377 when: parts.next().unwrap_or_default().trim().to_string(),
378 })
379 })
380 .filter(|commit| !commit.hash.is_empty())
381 .collect()
382 }
383
384 #[derive(Debug, Clone, PartialEq, Eq)]
385 pub struct WorktreeEntry {
386 pub path: PathBuf,
387 pub branch: Option<String>,
388 pub bare: bool,
389 pub locked: bool,
390 }
391
392 const CACHE_TTL: Duration = Duration::from_secs(2);
393
394 /// Probe cadence while the session is in use. One probe is about a dozen
395 /// `git` processes, none of them counted in the TUI's own CPU time.
396 pub(crate) const ACTIVE_PROBE_INTERVAL: Duration = CACHE_TTL;
397
398 /// How long a session must see no input and no engine event before the probe
399 /// backs off to [`QUIET_PROBE_INTERVAL`] (#6728).
400 pub(crate) const QUIET_PROBE_AFTER: Duration = Duration::from_secs(30);
401
402 /// Probe cadence of a session nobody is touching. Input or any engine event
403 /// (a tool finishing, a turn ending) ends the quiet and the next tick probes.
404 pub(crate) const QUIET_PROBE_INTERVAL: Duration = Duration::from_secs(15);
405
406 /// Cache lifetime while backed off. Just under [`QUIET_PROBE_INTERVAL`], so
407 /// the 15 s workspace-context refresh finds the loop's probe fresh instead of
408 /// adding a second one of its own.
409 const QUIET_CACHE_TTL: Duration = Duration::from_secs(14);
410
411 static PROBE_BACKED_OFF: AtomicBool = AtomicBool::new(false);
412
413 /// Whether a session quiet for `quiet_for` is on the slow probe schedule.
414 #[must_use]
415 pub(crate) fn probe_is_backed_off(quiet_for: Duration) -> bool {
416 quiet_for >= QUIET_PROBE_AFTER
417 }
418
419 /// The gap between probes for a session quiet for `quiet_for`.
420 #[must_use]
421 pub(crate) fn probe_interval(quiet_for: Duration) -> Duration {
422 if probe_is_backed_off(quiet_for) {
423 QUIET_PROBE_INTERVAL
424 } else {
425 ACTIVE_PROBE_INTERVAL
426 }
427 }
428
429 /// Whether a probe is due, `last_probe_age` after the previous one (`None`:
430 /// never probed). Fresh input shortens the interval, so a probe that is
431 /// older than the fast cadence fires on the very next tick.
432 #[must_use]
433 pub(crate) fn probe_due(last_probe_age: Option<Duration>, quiet_for: Duration) -> bool {
434 last_probe_age.is_none_or(|age| age >= probe_interval(quiet_for))
435 }
436
437 /// Tell the cache which schedule the loop is on, so [`refresh_if_stale`]
438 /// callers agree with it about what "fresh" means.
439 pub(crate) fn set_probe_backoff(backed_off: bool) {
440 PROBE_BACKED_OFF.store(backed_off, Ordering::Relaxed);
441 }
442
443 fn cache_ttl() -> Duration {
444 if PROBE_BACKED_OFF.load(Ordering::Relaxed) {
445 QUIET_CACHE_TTL
446 } else {
447 CACHE_TTL
448 }
449 }
450
451 static CACHE: OnceLock<Mutex<GitStatusSnapshot>> = OnceLock::new();
452
453 fn cache() -> &'static Mutex<GitStatusSnapshot> {
454 CACHE.get_or_init(|| Mutex::new(GitStatusSnapshot::default()))
455 }
456
457 /// Return the last known snapshot without blocking.
458 #[must_use]
459 pub fn cached_status() -> GitStatusSnapshot {
460 cache().lock().map(|g| g.clone()).unwrap_or_default()
461 }
462
463 /// Refresh status if the cache is stale. Safe to call from a background
464 /// worker; the render path should only read [`cached_status`].
465 /// Whether `snap` must be re-probed for `workspace`.
466 ///
467 /// Split out and pure so the cache contract is testable without spawning
468 /// git. The workspace comparison uses [`GitStatusSnapshot::probed_workspace`]
469 /// deliberately: comparing `root` instead meant that any session launched
470 /// below the repository top level saw `root != workspace` forever, so this
471 /// returned `true` on every call and `CACHE_TTL` never applied. That turned
472 /// the two-second chrome tick into an unconditional six-command probe —
473 /// including the `git status` that contends for `.git/index.lock` (#5617).
474 fn snapshot_is_stale(snap: &GitStatusSnapshot, workspace: &Path) -> bool {
475 snap.fetched_at.is_none_or(|t| t.elapsed() > cache_ttl())
476 || snap.probed_workspace.as_deref() != Some(workspace)
477 }
478
479 /// Returns the snapshot for `workspace`: the cached one while fresh, else a
480 /// new probe (which also becomes the cache).
481 pub fn refresh_if_stale(workspace: &Path) -> GitStatusSnapshot {
482 let cached = cache().lock().ok().map(|g| g.clone());
483 if let Some(snap) = cached.as_ref().filter(|g| !snapshot_is_stale(g, workspace)) {
484 return snap.clone();
485 }
486 // Backed off, with a healthy snapshot of this workspace in hand: re-read
487 // the status only (#6728).
488 if PROBE_BACKED_OFF.load(Ordering::Relaxed)
489 && let Some(prev) = cached.filter(|prev| can_probe_status_only(prev, workspace))
490 {
491 let snap = probe_status_only(&prev);
492 if let Ok(mut guard) = cache().lock() {
493 *guard = snap.clone();
494 }
495 return snap;
496 }
497 force_refresh(workspace)
498 }
499
500 /// Force a refresh (e.g. after checkout / worktree create).
501 pub fn force_refresh(workspace: &Path) -> GitStatusSnapshot {
502 let snap = probe_status(workspace);
503 if let Ok(mut guard) = cache().lock() {
504 *guard = snap.clone();
505 }
506 snap
507 }
508
509 pub(crate) fn probe_status(workspace: &Path) -> GitStatusSnapshot {
510 let mut snap = GitStatusSnapshot {
511 fetched_at: Some(Instant::now()),
512 probed_workspace: Some(workspace.to_path_buf()),
513 ..GitStatusSnapshot::default()
514 };
515
516 // Fast-fail outside a repository. Without this a non-git workspace
517 // spawns a doomed `git` process on every tick forever. `find_git_root`
518 // walks parents and understands the `gitdir:` pointer file, so linked
519 // worktrees and submodules are still recognised — a bare `.git`
520 // directory test would not be (#5617). The `rev-parse` below still runs
521 // for the cases this cannot see, such as bare repositories.
522 if crate::project_context::find_git_root(workspace).is_none() {
523 snap.error = Some("not a git repository".into());
524 return snap;
525 }
526
527 // Resolve git root.
528 let root = match git_output(workspace, &["rev-parse", "--show-toplevel"]) {
529 Ok(root) => PathBuf::from(root.trim()),
530 Err(error) => {
531 snap.error = Some(if error.contains("not a git repository") {
532 "not a git repository".into()
533 } else {
534 format!("git unavailable: {}", error.trim())
535 });
536 return snap;
537 }
538 };
539 snap.root = Some(root.clone());
540 let (repository_name, is_linked_worktree) = repository_identity(&root);
541 snap.repository_name = repository_name;
542 snap.is_linked_worktree = is_linked_worktree;
543
544 // Branch, upstream, ahead/behind and every changed path: one call.
545 match probe_workspace_status(&root) {
546 Ok(status) => apply_porcelain(&mut snap, status),
547 Err(error) => {
548 snap.error = Some(format!("git status failed: {}", error.trim()));
549 return snap;
550 }
551 }
552
553 // The forge slug (`owner/name`), reusing the remote-control probe rather
554 // than parsing `origin` a second time. Rides this cached probe so the
555 // topbar never shells out per frame.
556 snap.remote_slug = crate::remote_control::observed_git_repo(&root);
557
558 if let Ok(log) = git_output(&root, &["log", RECENT_COMMITS, "--format=%h%x1f%s%x1f%cr"]) {
559 snap.recent_commits = parse_recent_commits(&log);
560 }
561
562 // Worktrees.
563 if let Ok(list) = git_output(&root, &["worktree", "list", "--porcelain"]) {
564 snap.worktrees = parse_worktree_list(&list);
565 }
566
567 snap
568 }
569
570 /// Fold one `git status` reading into `snap`: branch, upstream, ahead/behind
571 /// and every changed path.
572 fn apply_porcelain(snap: &mut GitStatusSnapshot, status: PorcelainStatus) {
573 snap.detached = status.head.is_none();
574 snap.branch = status
575 .head
576 .clone()
577 .or_else(|| status.oid.as_deref().map(|oid| short_oid(oid).to_string()));
578 snap.has_upstream = status.upstream.is_some();
579 snap.ahead = status.ahead;
580 snap.behind = status.behind;
581 snap.dirty = !status.changes.is_clean();
582 snap.changes = status.changes;
583 snap.changed_paths = status.changed_paths;
584 snap.changed_path_count = status.changed_path_count;
585 }
586
587 /// Whether a backed-off refresh may re-read only `git status` on top of
588 /// `prev`. It needs a healthy earlier probe of the same workspace: the rest
589 /// (repository identity, forge slug, recent commits, worktrees) only changes
590 /// through something the person or the agent does, and that is activity,
591 /// which ends the back-off and brings the full probe back.
592 fn can_probe_status_only(prev: &GitStatusSnapshot, workspace: &Path) -> bool {
593 prev.probed_workspace.as_deref() == Some(workspace)
594 && prev.error.is_none()
595 && prev.root.is_some()
596 }
597
598 /// The back-off probe: one `git status` (two processes) instead of the dozen
599 /// the full probe starts, spliced into `prev`. Branch, dirty state and
600 /// ahead/behind still follow whatever happens in another terminal; the
601 /// commit list and worktree list wait for the next full probe (#6728).
602 ///
603 /// Known limits: while backed off, a commit or worktree change made in another
604 /// terminal shows in the branch and dirty state within [`QUIET_PROBE_INTERVAL`]
605 /// but not in the recent-commit and worktree lists, and the relative ages in
606 /// those lists stand still, until the next input or engine event brings the
607 /// full probe back.
608 fn probe_status_only(prev: &GitStatusSnapshot) -> GitStatusSnapshot {
609 let mut snap = prev.clone();
610 snap.fetched_at = Some(Instant::now());
611 let Some(root) = prev.root.as_deref() else {
612 return snap;
613 };
614 match probe_workspace_status(root) {
615 Ok(status) => apply_porcelain(&mut snap, status),
616 Err(error) => snap.error = Some(format!("git status failed: {}", error.trim())),
617 }
618 snap
619 }
620
621 fn parse_worktree_list(porcelain: &str) -> Vec<WorktreeEntry> {
622 let mut entries = Vec::new();
623 let mut current: Option<WorktreeEntry> = None;
624 for line in porcelain.lines() {
625 if let Some(path) = line.strip_prefix("worktree ") {
626 if let Some(entry) = current.take() {
627 entries.push(entry);
628 }
629 current = Some(WorktreeEntry {
630 path: PathBuf::from(path),
631 branch: None,
632 bare: false,
633 locked: false,
634 });
635 } else if let Some(entry) = current.as_mut() {
636 if let Some(branch) = line.strip_prefix("branch refs/heads/") {
637 entry.branch = Some(branch.to_string());
638 } else if line == "bare" {
639 entry.bare = true;
640 } else if line.starts_with("locked") {
641 entry.locked = true;
642 }
643 }
644 }
645 if let Some(entry) = current {
646 entries.push(entry);
647 }
648 entries
649 }
650
651 fn git_output(cwd: &Path, args: &[&str]) -> Result<String, String> {
652 // Shared read policy disables repository-selected helpers and lazy
653 // fetch while keeping the sanitized environment and optional locks off.
654 let output = Git::review_command(cwd)
655 .map_err(|e| format!("{e:#}"))?
656 .args(["-c", "log.showSignature=false"])
657 .args(args)
658 .output()
659 .map_err(|e| e.to_string())?;
660 finish_git_output(output)
661 }
662
663 // Explicit writes keep the user's own configured behavior. The shared Git
664 // command still scrubs parent credentials and never opens a hidden prompt.
665 fn git_write(cwd: &Path, args: &[&str]) -> Result<String, String> {
666 finish_git_output(Git::output(args, cwd).map_err(|e| e.to_string())?)
667 }
668
669 fn finish_git_output(output: std::process::Output) -> Result<String, String> {
670 if !output.status.success() {
671 return Err(String::from_utf8_lossy(&output.stderr).into_owned());
672 }
673 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
674 }
675
676 /// The repository's name (from the common git directory, so a linked
677 /// worktree names its repository) and whether this checkout is a linked
678 /// worktree, from one `rev-parse`.
679 fn repository_identity(worktree_root: &Path) -> (Option<String>, bool) {
680 let Ok(paths) = git_output(
681 worktree_root,
682 &[
683 "rev-parse",
684 "--path-format=absolute",
685 "--git-dir",
686 "--git-common-dir",
687 ],
688 ) else {
689 return (None, false);
690 };
691 let mut lines = paths.lines().map(str::trim);
692 let (Some(git_dir), Some(common_dir)) = (lines.next(), lines.next()) else {
693 return (None, false);
694 };
695 (
696 repository_name_from_common_dir(worktree_root, Path::new(common_dir)),
697 git_dir != common_dir,
698 )
699 }
700
701 fn repository_name_from_common_dir(worktree_root: &Path, common_dir: &Path) -> Option<String> {
702 let common_dir = if common_dir.is_absolute() {
703 common_dir.to_path_buf()
704 } else {
705 worktree_root.join(common_dir)
706 };
707 common_dir
708 .parent()
709 .and_then(Path::file_name)
710 .map(|name| name.to_string_lossy().into_owned())
711 }
712
713 /// Compact chrome label: `CodeWhale · main* ↑2` or
714 /// `CodeWhale/feature · feature*` for a linked worktree.
715 ///
716 /// Omits the segment when Git has not named a location or ref. A known
717 /// location without a branch still renders — the header must not invent a
718 /// ref to fill the slot.
719 #[must_use]
720 pub fn chrome_label(snap: &GitStatusSnapshot) -> Option<String> {
721 let worktree_name = snap
722 .root
723 .as_deref()
724 .and_then(Path::file_name)
725 .map(|name| name.to_string_lossy());
726 let location = match (snap.repository_name.as_deref(), worktree_name.as_deref()) {
727 (Some(repository), Some(worktree)) if repository != worktree => {
728 Some(format!("{repository}/{worktree}"))
729 }
730 (Some(repository), _) => Some(repository.to_string()),
731 (None, Some(worktree)) => Some(worktree.to_string()),
732 (None, None) => None,
733 };
734 let mut label = match (location, snap.branch.as_deref()) {
735 (Some(location), Some(branch)) => format!("{location} · {branch}"),
736 (Some(location), None) => location,
737 (None, Some(branch)) => branch.to_string(),
738 (None, None) => return None,
739 };
740 if snap.dirty {
741 label.push('*');
742 }
743 if snap.ahead > 0 {
744 label.push_str(&format!(" ↑{}", snap.ahead));
745 }
746 if snap.behind > 0 {
747 label.push_str(&format!(" ↓{}", snap.behind));
748 }
749 Some(label)
750 }
751
752 /// Status-bar ink for repository chrome. Location is metadata, not a
753 /// failure — dirtiness is the `*` on the same gray string.
754 #[must_use]
755 pub fn chrome_ink() -> codewhale_palette::ChromeInk {
756 codewhale_palette::ChromeInk::Metadata
757 }
758
759 /// Create a new worktree at `path` tracking `branch` (or a new branch name).
760 pub fn create_worktree(
761 repo: &Path,
762 path: &Path,
763 branch: &str,
764 new_branch: bool,
765 ) -> Result<(), String> {
766 let mut args = vec!["worktree", "add"];
767 if new_branch {
768 args.push("-b");
769 args.push(branch);
770 args.push(path.to_str().ok_or("invalid path")?);
771 } else {
772 args.push(path.to_str().ok_or("invalid path")?);
773 args.push(branch);
774 }
775 git_write(repo, &args).map(|_| ())?;
776 force_refresh(repo);
777 Ok(())
778 }
779
780 #[cfg(test)]
781 mod tests {
782 use super::*;
783
784 fn probed(workspace: &Path, root: &Path) -> GitStatusSnapshot {
785 GitStatusSnapshot {
786 root: Some(root.to_path_buf()),
787 probed_workspace: Some(workspace.to_path_buf()),
788 fetched_at: Some(Instant::now()),
789 ..GitStatusSnapshot::default()
790 }
791 }
792
793 /// The cache TTL must actually apply when the session was launched below
794 /// the repository top level. Comparing `root` to the workspace made this
795 /// permanently stale, so the two-second chrome probe ran unconditionally
796 /// and `git status` contended for the user's index lock (#5617).
797 #[test]
798 fn fresh_snapshot_from_a_subdirectory_is_not_stale() {
799 let root = PathBuf::from("/repo");
800 let workspace = PathBuf::from("/repo/crates/tui");
801 let snap = probed(&workspace, &root);
802 assert_ne!(snap.root.as_deref(), Some(workspace.as_path()));
803 assert!(
804 !snapshot_is_stale(&snap, &workspace),
805 "a fresh probe from a subdirectory must satisfy the TTL"
806 );
807 }
808
809 /// The probe schedule of #6728: two seconds while the session is in use,
810 /// fifteen once nothing has touched it for thirty, and the fast cadence
811 /// back on the very next tick after any activity.
812 #[test]
813 fn the_probe_backs_off_only_after_thirty_quiet_seconds() {
814 let secs = Duration::from_secs;
815 assert_eq!(probe_interval(secs(0)), secs(2));
816 assert_eq!(probe_interval(secs(29)), secs(2));
817 assert!(!probe_is_backed_off(secs(29)));
818 assert_eq!(probe_interval(secs(30)), secs(15));
819 assert!(probe_is_backed_off(secs(30)));
820 assert_eq!(probe_interval(secs(3_600)), secs(15));
821 }
822
823 #[test]
824 fn a_probe_is_due_on_its_interval_and_at_once_after_activity() {
825 let secs = Duration::from_secs;
826 // Never probed: always due.
827 assert!(probe_due(None, secs(0)));
828 assert!(probe_due(None, secs(600)));
829 // In use: the 2 s cadence.
830 assert!(!probe_due(Some(secs(1)), secs(5)));
831 assert!(probe_due(Some(secs(2)), secs(5)));
832 // Quiet: nothing until 15 s.
833 assert!(!probe_due(Some(secs(2)), secs(60)));
834 assert!(!probe_due(Some(secs(14)), secs(60)));
835 assert!(probe_due(Some(secs(15)), secs(60)));
836 // The next input resets the quiet clock; a probe 9 s old is now due.
837 assert!(!probe_due(Some(secs(9)), secs(60)));
838 assert!(probe_due(Some(secs(9)), secs(0)));
839 }
840
841 #[test]
842 fn a_status_only_refresh_needs_a_healthy_snapshot_of_the_same_workspace() {
843 let workspace = PathBuf::from("/repo/crates/tui");
844 let healthy = probed(&workspace, Path::new("/repo"));
845 assert!(can_probe_status_only(&healthy, &workspace));
846 assert!(
847 !can_probe_status_only(&healthy, Path::new("/other")),
848 "another workspace gets the full probe"
849 );
850 let mut errored = healthy.clone();
851 errored.error = Some("git status failed: boom".into());
852 assert!(!can_probe_status_only(&errored, &workspace));
853 let mut rootless = healthy;
854 rootless.root = None;
855 assert!(!can_probe_status_only(&rootless, &workspace));
856 assert!(!can_probe_status_only(
857 &GitStatusSnapshot::default(),
858 &workspace
859 ));
860 }
861
862 /// The back-off probe runs one `git status` and leaves everything else
863 /// the full probe learned in place.
864 #[test]
865 fn a_status_only_refresh_keeps_identity_commits_and_worktrees() {
866 let dir = tempfile::tempdir().expect("tempdir");
867 let git = |args: &[&str]| {
868 let out = std::process::Command::new("git")
869 .args(["-c", "user.name=t", "-c", "user.email=t@example.invalid"])
870 .args(args)
871 .current_dir(dir.path())
872 .output()
873 .expect("git runs");
874 assert!(out.status.success(), "git {args:?}: {out:?}");
875 };
876 git(&["init", "-q", "-b", "main"]);
877 std::fs::write(dir.path().join("a.txt"), "a\n").expect("write");
878 git(&["add", "-A"]);
879 git(&["commit", "-q", "-m", "first"]);
880
881 let full = probe_status(dir.path());
882 assert!(full.error.is_none(), "{:?}", full.error);
883 assert!(!full.dirty);
884 assert_eq!(full.recent_commits.len(), 1);
885 assert!(can_probe_status_only(&full, dir.path()));
886
887 std::fs::write(dir.path().join("b.txt"), "b\n").expect("write");
888 let light = probe_status_only(&full);
889 assert!(light.error.is_none(), "{:?}", light.error);
890 assert!(light.dirty, "the new untracked file shows up");
891 assert_eq!(light.changes.untracked, 1);
892 assert_eq!(light.branch.as_deref(), Some("main"));
893 assert_eq!(light.recent_commits, full.recent_commits);
894 assert_eq!(light.worktrees, full.worktrees);
895 assert_eq!(light.repository_name, full.repository_name);
896 assert_eq!(light.root, full.root);
897 assert_eq!(light.probed_workspace, full.probed_workspace);
898 assert!(light.fetched_at >= full.fetched_at);
899 }
900
901 #[test]
902 fn the_quiet_cache_lifetime_stays_below_the_quiet_probe_interval() {
903 // Otherwise the loop's own probe would find its previous result
904 // fresh and skip, and the cadence would silently double.
905 assert!(QUIET_CACHE_TTL < QUIET_PROBE_INTERVAL);
906 assert!(QUIET_CACHE_TTL > CACHE_TTL);
907 }
908
909 #[test]
910 fn a_different_workspace_is_always_stale() {
911 let snap = probed(Path::new("/repo/crates/tui"), Path::new("/repo"));
912 assert!(snapshot_is_stale(&snap, Path::new("/other")));
913 }
914
915 #[test]
916 fn an_unprobed_snapshot_is_stale() {
917 assert!(snapshot_is_stale(
918 &GitStatusSnapshot::default(),
919 Path::new("/repo")
920 ));
921 }
922
923 /// A workspace outside any repository must resolve without spawning git,
924 /// and must record its own input so the TTL suppresses the next tick.
925 #[test]
926 fn non_git_workspace_fast_fails_and_caches_its_workspace() {
927 let dir = tempfile::tempdir().expect("tempdir");
928 let snap = probe_status(dir.path());
929 assert_eq!(snap.error.as_deref(), Some("not a git repository"));
930 assert_eq!(snap.root, None);
931 assert_eq!(snap.probed_workspace.as_deref(), Some(dir.path()));
932 assert!(
933 !snapshot_is_stale(&snap, dir.path()),
934 "the negative result must be cached, not re-probed every tick"
935 );
936 }
937
938 #[test]
939 fn parse_worktree_porcelain() {
940 let raw = "\
941 worktree /repo
942 HEAD abc
943 branch refs/heads/main
944
945 worktree /repo/.cw-worktrees/feat
946 HEAD def
947 branch refs/heads/feat
948 locked
949 ";
950 let entries = parse_worktree_list(raw);
951 assert_eq!(entries.len(), 2);
952 assert_eq!(entries[0].branch.as_deref(), Some("main"));
953 assert!(entries[1].locked);
954 assert_eq!(entries[1].branch.as_deref(), Some("feat"));
955 }
956
957 #[test]
958 fn chrome_label_marks_dirty_and_divergence() {
959 let snap = GitStatusSnapshot {
960 root: Some("/repo".into()),
961 repository_name: Some("repo".into()),
962 branch: Some("main".into()),
963 dirty: true,
964 ahead: 2,
965 behind: 1,
966 ..GitStatusSnapshot::default()
967 };
968 assert_eq!(chrome_label(&snap).as_deref(), Some("repo · main* ↑2 ↓1"));
969 }
970
971 #[test]
972 fn chrome_label_identifies_a_linked_worktree() {
973 let snap = GitStatusSnapshot {
974 root: Some("/repo/.cw-worktrees/feature".into()),
975 repository_name: Some("repo".into()),
976 branch: Some("feature".into()),
977 dirty: true,
978 ..GitStatusSnapshot::default()
979 };
980
981 assert_eq!(
982 chrome_label(&snap).as_deref(),
983 Some("repo/feature · feature*")
984 );
985 }
986
987 #[test]
988 fn chrome_label_omits_dirty_marker_when_clean() {
989 let snap = GitStatusSnapshot {
990 root: Some("/repo".into()),
991 repository_name: Some("repo".into()),
992 branch: Some("main".into()),
993 dirty: false,
994 ..GitStatusSnapshot::default()
995 };
996 assert_eq!(chrome_label(&snap).as_deref(), Some("repo · main"));
997 }
998
999 #[test]
1000 fn chrome_label_keeps_location_when_the_ref_is_unknown() {
1001 let snap = GitStatusSnapshot {
1002 root: Some("/repo/.cw-worktrees/feature".into()),
1003 repository_name: Some("repo".into()),
1004 branch: None,
1005 dirty: true,
1006 ..GitStatusSnapshot::default()
1007 };
1008 assert_eq!(chrome_label(&snap).as_deref(), Some("repo/feature*"));
1009 }
1010
1011 #[test]
1012 fn chrome_label_is_absent_without_a_repo_or_ref() {
1013 assert_eq!(
1014 chrome_label(&GitStatusSnapshot {
1015 error: Some("not a git repository".into()),
1016 ..GitStatusSnapshot::default()
1017 }),
1018 None
1019 );
1020 }
1021
1022 #[test]
1023 fn chrome_ink_is_metadata_not_failure() {
1024 assert_eq!(chrome_ink(), codewhale_palette::ChromeInk::Metadata);
1025 assert_eq!(
1026 chrome_ink().family(),
1027 codewhale_palette::SemanticFamily::Neutral
1028 );
1029 }
1030
1031 #[test]
1032 fn repository_name_uses_the_common_git_directory_for_worktrees() {
1033 assert_eq!(
1034 repository_name_from_common_dir(
1035 Path::new("/repo/.cw-worktrees/feature"),
1036 Path::new("/repo/.git")
1037 )
1038 .as_deref(),
1039 Some("repo")
1040 );
1041 assert_eq!(
1042 repository_name_from_common_dir(Path::new("/repo"), Path::new(".git")).as_deref(),
1043 Some("repo")
1044 );
1045 }
1046
1047 #[test]
1048 fn porcelain_v2_carries_branch_upstream_divergence_and_every_change() {
1049 let raw = [
1050 "# branch.oid 1234567890abcdef1234567890abcdef12345678",
1051 "# branch.head main",
1052 "# branch.upstream origin/main",
1053 "# branch.ab +2 -1",
1054 "1 M. N... 100644 100644 100644 aaa bbb src/lib.rs",
1055 "1 .M N... 100644 100644 100644 aaa bbb docs/a file.md",
1056 "2 R. N... 100644 100644 100644 aaa bbb R100 new.rs",
1057 "old.rs",
1058 "u UU N... 100644 100644 100644 100644 aaa bbb ccc conflict.rs",
1059 "? scratch.txt",
1060 "! target",
1061 "",
1062 ]
1063 .join("\0");
1064 let status = parse_porcelain_v2(&raw).expect("porcelain v2");
1065 assert_eq!(status.head.as_deref(), Some("main"));
1066 assert_eq!(status.upstream.as_deref(), Some("origin/main"));
1067 assert_eq!((status.ahead, status.behind), (2, 1));
1068 assert_eq!(
1069 status.changes,
1070 ChangeCounts {
1071 // The UU record is a conflict only, not also staged and
1072 // modified (U08-m2).
1073 staged: 2,
1074 modified: 1,
1075 untracked: 1,
1076 conflicts: 1,
1077 }
1078 );
1079 let paths = status
1080 .changed_paths
1081 .iter()
1082 .map(|path| (path.code.as_str(), path.path.as_str()))
1083 .collect::<Vec<_>>();
1084 assert_eq!(
1085 paths,
1086 [
1087 ("M ", "src/lib.rs"),
1088 (" M", "docs/a file.md"),
1089 ("R ", "new.rs"),
1090 ("UU", "conflict.rs"),
1091 ("??", "scratch.txt"),
1092 ]
1093 );
1094 assert_eq!(
1095 status_line(&status).as_deref(),
1096 Some("main | 2 staged, 1 modified, 1 untracked, 1 conflicts")
1097 );
1098 }
1099
1100 #[test]
1101 fn all_unmerged_records_are_conflicts() {
1102 for code in ["AA", "DD", "AU", "UA", "DU", "UD", "UU"] {
1103 let raw = format!(
1104 "# branch.head main\0u {code} N... 100644 100644 100644 100644 aaa bbb ccc conflict.rs\0"
1105 );
1106 let status = parse_porcelain_v2(&raw).unwrap();
1107 assert_eq!(status.changes.conflicts, 1, "{code}");
1108 assert_eq!(
1109 (status.changes.staged, status.changes.modified),
1110 (0, 0),
1111 "{code} is not also staged or modified"
1112 );
1113 assert!(status_line(&status).unwrap().contains("1 conflicts"));
1114 let (legacy, _, _) = parse_porcelain_v1(&format!("{code} conflict.rs\n"));
1115 assert_eq!(legacy.conflicts, 1, "legacy {code}");
1116 assert_eq!((legacy.staged, legacy.modified), (0, 0), "legacy {code}");
1117 }
1118 }
1119
1120 #[test]
1121 fn changed_path_count_survives_the_display_cap() {
1122 for count in [1, MAX_CHANGED_PATHS, MAX_CHANGED_PATHS + 1] {
1123 let mut raw = "# branch.head main\0".to_string();
1124 let mut legacy = String::new();
1125 for index in 0..count {
1126 raw.push_str(&format!(
1127 "1 MM N... 100644 100644 100644 aaa bbb file-{index}\0"
1128 ));
1129 legacy.push_str(&format!("MM file-{index}\n"));
1130 }
1131 let status = parse_porcelain_v2(&raw).unwrap();
1132 assert_eq!(status.changed_path_count, count);
1133 assert_eq!(status.changed_paths.len(), count.min(MAX_CHANGED_PATHS));
1134 let (_, paths, path_count) = parse_porcelain_v1(&legacy);
1135 assert_eq!(path_count, count);
1136 assert_eq!(paths.len(), count.min(MAX_CHANGED_PATHS));
1137 }
1138 }
1139
1140 #[test]
1141 fn porcelain_v2_detached_and_unborn_heads() {
1142 let detached =
1143 parse_porcelain_v2("# branch.oid 1234567890abcdef\0# branch.head (detached)\0")
1144 .expect("v2");
1145 assert_eq!(detached.head, None);
1146 assert_eq!(
1147 status_line(&detached).as_deref(),
1148 Some("detached:1234567 | clean")
1149 );
1150 let unborn =
1151 parse_porcelain_v2("# branch.oid (initial)\0# branch.head main\0").expect("v2");
1152 assert_eq!(unborn.oid, None);
1153 assert_eq!(status_line(&unborn).as_deref(), Some("main | clean"));
1154 // Porcelain v1 (a git too old for v2) has no branch header: the
1155 // caller falls back to the old calls.
1156 assert_eq!(parse_porcelain_v2(" M src/lib.rs\n?? new.rs\n"), None);
1157 let (counts, paths, _) = parse_porcelain_v1(" M src/lib.rs\n?? new.rs\n");
1158 assert_eq!((counts.modified, counts.untracked), (1, 1));
1159 assert_eq!(paths[1].path, "new.rs");
1160 }
1161
1162 #[test]
1163 fn recent_commits_parse_the_unit_separated_log() {
1164 let commits = parse_recent_commits("abc1234\u{1f}fix: a thing\u{1f}3 hours ago\nbad\n");
1165 assert_eq!(
1166 commits,
1167 [RecentCommit {
1168 hash: "abc1234".to_string(),
1169 subject: "fix: a thing".to_string(),
1170 when: "3 hours ago".to_string(),
1171 }]
1172 );
1173 }
1174
1175 fn git(dir: &Path, args: &[&str]) {
1176 let mut all = vec![
1177 "-c",
1178 "user.name=Fixture",
1179 "-c",
1180 "user.email=fixture@example.invalid",
1181 "-c",
1182 "commit.gpgsign=false",
1183 ];
1184 all.extend_from_slice(args);
1185 git_write(dir, &all).expect("git");
1186 }
1187
1188 /// Even status may run repository-selected fsmonitor or clean-filter
1189 /// code. A background read must neither run it nor expose parent env.
1190 #[cfg(unix)]
1191 #[test]
1192 fn automatic_git_status_does_not_execute_repository_helpers() {
1193 use std::os::unix::fs::PermissionsExt;
1194 let _lock = crate::test_support::lock_test_env();
1195 let _sentinel = crate::test_support::EnvVarGuard::set(
1196 "CODEWHALE_TEST_GIT_POLL_SECRET",
1197 "git-poll-sentinel",
1198 );
1199 let dir = tempfile::tempdir().expect("repo");
1200 let hooks = tempfile::tempdir().expect("private hooks");
1201 let repo = dir.path();
1202 git(repo, &["init", "--initial-branch=main"]);
1203 std::fs::write(repo.join("tracked.txt"), "one\n").unwrap();
1204 std::fs::write(repo.join(".gitattributes"), "*.txt filter=fixture\n").unwrap();
1205 git(repo, &["add", "."]);
1206 git(repo, &["commit", "-m", "first commit"]);
1207 let fsmonitor_seen = hooks.path().join("fsmonitor-seen");
1208 let filter_seen = hooks.path().join("filter-seen");
1209 for (name, marker, ending) in [
1210 ("fsmonitor.sh", &fsmonitor_seen, "exit 1"),
1211 ("clean.sh", &filter_seen, "cat"),
1212 ] {
1213 let script = hooks.path().join(name);
1214 std::fs::write(
1215 &script,
1216 format!(
1217 "#!/bin/sh\nprintf 'leak=%s\\n' \"${{CODEWHALE_TEST_GIT_POLL_SECRET-unset}}\" >> '{}'\n{ending}\n",
1218 marker.display(),
1219 ),
1220 )
1221 .unwrap();
1222 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1223 }
1224 git(
1225 repo,
1226 &[
1227 "config",
1228 "core.fsmonitor",
1229 &hooks.path().join("fsmonitor.sh").to_string_lossy(),
1230 ],
1231 );
1232 git(
1233 repo,
1234 &[
1235 "config",
1236 "filter.fixture.clean",
1237 &hooks.path().join("clean.sh").to_string_lossy(),
1238 ],
1239 );
1240 git(repo, &["config", "filter.fixture.required", "true"]);
1241 // Same length forces content comparison instead of the cheap size check.
1242 std::fs::write(repo.join("tracked.txt"), "two\n").unwrap();
1243 let snapshot = probe_status(repo);
1244 assert_eq!(snapshot.error, None);
1245 assert_eq!(snapshot.branch.as_deref(), Some("main"));
1246 assert!(
1247 snapshot.dirty && snapshot.changes.modified >= 1,
1248 "{snapshot:?}"
1249 );
1250 for marker in [&fsmonitor_seen, &filter_seen] {
1251 assert!(
1252 !marker.exists(),
1253 "automatic read ran a repository helper: {}",
1254 std::fs::read_to_string(marker).unwrap_or_default(),
1255 );
1256 }
1257 }
1258
1259 /// log.showSignature can run the repository's gpg.program even with
1260 /// captured stdout. Recent-commit polling never requests verification.
1261 #[cfg(unix)]
1262 #[test]
1263 fn automatic_git_log_does_not_execute_a_signature_helper() {
1264 use std::io::Write as _;
1265 use std::os::unix::fs::PermissionsExt;
1266 use std::process::Stdio;
1267 let _lock = crate::test_support::lock_test_env();
1268 let _sentinel = crate::test_support::EnvVarGuard::set(
1269 "CODEWHALE_TEST_GIT_POLL_SECRET",
1270 "git-poll-sentinel",
1271 );
1272 let dir = tempfile::tempdir().expect("repo");
1273 let hooks = tempfile::tempdir().expect("private hooks");
1274 let repo = dir.path();
1275 git(repo, &["init", "--initial-branch=main"]);
1276 std::fs::write(repo.join("tracked.txt"), "one\n").unwrap();
1277 git(repo, &["add", "."]);
1278 git(repo, &["commit", "-m", "first commit"]);
1279 let unsigned = git_write(repo, &["cat-file", "commit", "HEAD"]).unwrap();
1280 let (headers, message) = unsigned.split_once("\n\n").unwrap();
1281 let signed = format!(
1282 "{headers}\ngpgsig -----BEGIN PGP SIGNATURE-----\n fixture\n -----END PGP SIGNATURE-----\n\n{message}"
1283 );
1284 let mut child = Git::command()
1285 .expect("git available")
1286 .args(["hash-object", "-t", "commit", "-w", "--stdin"])
1287 .current_dir(repo)
1288 .stdin(Stdio::piped())
1289 .stdout(Stdio::piped())
1290 .spawn()
1291 .unwrap();
1292 child
1293 .stdin
1294 .take()
1295 .unwrap()
1296 .write_all(signed.as_bytes())
1297 .unwrap();
1298 let object = child.wait_with_output().unwrap();
1299 assert!(object.status.success());
1300 let oid = String::from_utf8(object.stdout).unwrap();
1301 git(repo, &["update-ref", "HEAD", oid.trim()]);
1302 let marker = hooks.path().join("signature-seen");
1303 let script = hooks.path().join("signature.sh");
1304 std::fs::write(
1305 &script,
1306 format!(
1307 "#!/bin/sh\nprintf 'leak=%s\\n' \"${{CODEWHALE_TEST_GIT_POLL_SECRET-unset}}\" >> '{}'\nexit 1\n",
1308 marker.display(),
1309 ),
1310 )
1311 .unwrap();
1312 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1313 git(repo, &["config", "gpg.program", &script.to_string_lossy()]);
1314 git(
1315 repo,
1316 &["config", "gpg.openpgp.program", &script.to_string_lossy()],
1317 );
1318 git(repo, &["config", "log.showSignature", "true"]);
1319 let log = git_output(repo, &["log", "-1", "--format=%s"]).unwrap();
1320 assert!(log.contains("first commit"), "{log}");
1321 assert!(
1322 !marker.exists(),
1323 "automatic log ran a signature helper: {}",
1324 std::fs::read_to_string(marker).unwrap_or_default(),
1325 );
1326 }
1327
1328 /// One probe of a real repository: branch, changes, commits, and the
1329 /// badge string, through the single porcelain v2 status call.
1330 #[test]
1331 fn a_probe_of_a_real_repository_fills_the_git_view() {
1332 let dir = tempfile::tempdir().expect("tempdir");
1333 let repo = dir.path();
1334 git(repo, &["init", "--initial-branch=main"]);
1335 std::fs::write(repo.join("tracked.txt"), "one\n").unwrap();
1336 git(repo, &["add", "tracked.txt"]);
1337 git(repo, &["commit", "-m", "first commit"]);
1338 std::fs::write(repo.join("tracked.txt"), "two\n").unwrap();
1339 std::fs::write(repo.join("new.txt"), "new\n").unwrap();
1340
1341 let snap = probe_status(repo);
1342 assert_eq!(snap.error, None);
1343 assert_eq!(snap.branch.as_deref(), Some("main"));
1344 assert!(!snap.detached && !snap.has_upstream && !snap.is_linked_worktree);
1345 assert_eq!((snap.changes.modified, snap.changes.untracked), (1, 1));
1346 assert!(snap.dirty);
1347 assert_eq!(snap.changed_path_count, 2);
1348 assert_eq!(snap.recent_commits.len(), 1);
1349 assert_eq!(snap.recent_commits[0].subject, "first commit");
1350 assert_eq!(
1351 context_line(&snap).as_deref(),
1352 Some("main | 1 modified, 1 untracked")
1353 );
1354 // The engine's per-turn line reads the same parser and formatter.
1355 assert_eq!(
1356 crate::tui::workspace_context::collect(repo).as_deref(),
1357 Some("main | 1 modified, 1 untracked")
1358 );
1359 }
1360 }
1361
1361 lines RUST