Skip to content

Commit 46c0749

Browse files
committed
Fix workspace changes status for untracked files
workspace-changes previously only reported tracked line diffs, so a workspace with only untracked files looked clean and the statusline segment was omitted. Count untracked files separately and render them as a compact ?N suffix while keeping line counts bounded to tracked diffs. Also cover the async workspace-command AppEvent path so the statusline refresh contract is tested, not just the formatter. Tests: just fmt; cargo test -p codex-tui branch_summary; cargo test -p codex-tui status_line_workspace_changes; just fix -p codex-tui
1 parent 394f897 commit 46c0749

3 files changed

Lines changed: 163 additions & 6 deletions

File tree

codex-rs/tui/src/branch_summary.rs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ pub(crate) struct GitWorkspaceDiffStats {
3838
pub(crate) additions: u64,
3939
/// Total deleted lines in the current workspace.
4040
pub(crate) deletions: u64,
41+
/// Untracked files in the current workspace.
42+
pub(crate) untracked_files: u64,
4143
}
4244

4345
/// Combined git metadata cached by the status line for one working directory.
@@ -139,11 +141,10 @@ pub(crate) async fn status_line_git_summary(
139141
}
140142
}
141143

142-
/// Counts tracked workspace line changes relative to `HEAD`.
144+
/// Counts workspace changes relative to `HEAD`.
143145
///
144-
/// Untracked files are intentionally ignored for this compact status-line probe. Counting their
145-
/// lines requires full-file reads or expensive `--no-index` diffs, which is too heavy for a
146-
/// background UI refresh.
146+
/// Tracked changes are counted as line additions/deletions. Untracked files are counted as files,
147+
/// not lines, so the compact status-line probe stays bounded and avoids expensive full-file diffs.
147148
pub(crate) async fn workspace_diff_stats(
148149
runner: &dyn WorkspaceCommandExecutor,
149150
cwd: &Path,
@@ -163,9 +164,11 @@ pub(crate) async fn workspace_diff_stats(
163164
}
164165

165166
let (additions, deletions) = parse_numstat_totals(&numstat.stdout);
167+
let untracked_files = untracked_file_count(runner, cwd).await.unwrap_or(0);
166168
Some(GitWorkspaceDiffStats {
167169
additions,
168170
deletions,
171+
untracked_files,
169172
})
170173
}
171174

@@ -232,6 +235,26 @@ fn parse_numstat_totals(stdout: &str) -> (u64, u64) {
232235
})
233236
}
234237

238+
async fn untracked_file_count(runner: &dyn WorkspaceCommandExecutor, cwd: &Path) -> Option<u64> {
239+
let output = run_git_command(
240+
runner,
241+
cwd,
242+
&["ls-files", "--others", "--exclude-standard", "-z"],
243+
)
244+
.await
245+
.ok()?;
246+
if !output.success() {
247+
return None;
248+
}
249+
250+
let count = output
251+
.stdout
252+
.split('\0')
253+
.filter(|path| !path.is_empty())
254+
.count();
255+
Some(count as u64)
256+
}
257+
235258
/// Returns git remotes in the order used for default-branch discovery.
236259
///
237260
/// `origin` is prioritized because most repositories use it as the canonical upstream. Other
@@ -623,6 +646,11 @@ mod tests {
623646
/*exit_code*/ 0,
624647
"10\t2\tsrc/main.rs\n-\t-\tassets/logo.png\n3\t0\tREADME.md\n",
625648
),
649+
response(
650+
&["git", "ls-files", "--others", "--exclude-standard", "-z"],
651+
/*exit_code*/ 0,
652+
"scratch.txt\0notes/todo.md\0",
653+
),
626654
]);
627655

628656
let stats = workspace_diff_stats(&runner, Path::new("/repo"))
@@ -634,6 +662,7 @@ mod tests {
634662
GitWorkspaceDiffStats {
635663
additions: 13,
636664
deletions: 2,
665+
untracked_files: 2,
637666
}
638667
);
639668
}

codex-rs/tui/src/chatwidget/status_surfaces.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -653,8 +653,15 @@ impl ChatWidget {
653653
.status_line_workspace_changes
654654
.as_ref()
655655
.and_then(|stats| {
656-
(stats.additions != 0 || stats.deletions != 0)
657-
.then(|| format!("+{}/-{}", stats.additions, stats.deletions))
656+
if stats.additions == 0 && stats.deletions == 0 && stats.untracked_files == 0 {
657+
return None;
658+
}
659+
660+
let mut value = format!("+{}/-{}", stats.additions, stats.deletions);
661+
if stats.untracked_files > 0 {
662+
value.push_str(&format!(" ?{}", stats.untracked_files));
663+
}
664+
Some(value)
658665
}),
659666
StatusLineItem::Status => Some(self.run_state_status_text()),
660667
StatusLineItem::UsedTokens => {

codex-rs/tui/src/chatwidget/tests/status_and_layout.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ async fn status_line_workspace_changes_render_dirty_tracked_stats() {
289289
chat.status_line_workspace_changes = Some(crate::branch_summary::GitWorkspaceDiffStats {
290290
additions: 830,
291291
deletions: 281,
292+
untracked_files: 0,
292293
});
293294

294295
assert_eq!(
@@ -303,6 +304,7 @@ async fn status_line_workspace_changes_omits_clean_workspaces() {
303304
chat.status_line_workspace_changes = Some(crate::branch_summary::GitWorkspaceDiffStats {
304305
additions: 0,
305306
deletions: 0,
307+
untracked_files: 0,
306308
});
307309

308310
assert_eq!(
@@ -311,6 +313,69 @@ async fn status_line_workspace_changes_omits_clean_workspaces() {
311313
);
312314
}
313315

316+
#[tokio::test]
317+
async fn status_line_workspace_changes_render_untracked_file_count() {
318+
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
319+
chat.status_line_workspace_changes = Some(crate::branch_summary::GitWorkspaceDiffStats {
320+
additions: 0,
321+
deletions: 0,
322+
untracked_files: 3,
323+
});
324+
325+
assert_eq!(
326+
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WorkspaceChanges),
327+
Some("+0/-0 ?3".to_string())
328+
);
329+
}
330+
331+
#[tokio::test]
332+
async fn status_line_workspace_changes_async_event_renders_untracked_stats() {
333+
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
334+
chat.config.tui_status_line = Some(vec!["workspace-changes".to_string()]);
335+
let expected_cwd = chat.config.cwd.to_path_buf();
336+
install_scripted_workspace_command_runner(
337+
&mut chat,
338+
vec![
339+
workspace_response(
340+
&["git", "rev-parse", "--git-dir"],
341+
/*exit_code*/ 0,
342+
".git\n",
343+
),
344+
workspace_response(
345+
&["git", "diff", "--numstat", "HEAD", "--"],
346+
/*exit_code*/ 0,
347+
"2\t1\tsrc/lib.rs\n",
348+
),
349+
workspace_response(
350+
&["git", "ls-files", "--others", "--exclude-standard", "-z"],
351+
/*exit_code*/ 0,
352+
"scratch.txt\0",
353+
),
354+
],
355+
);
356+
chat.status_line_workspace_changes_cwd = None;
357+
chat.status_line_workspace_changes_lookup_complete = false;
358+
359+
chat.refresh_status_line();
360+
assert_eq!(status_line_text(&chat), None);
361+
362+
let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
363+
.await
364+
.expect("workspace changes event")
365+
.expect("workspace changes event");
366+
let crate::app_event::AppEvent::StatusLineWorkspaceChangesUpdated { cwd, stats } = event else {
367+
panic!("expected workspace changes event, got {event:?}");
368+
};
369+
assert_eq!(cwd, expected_cwd);
370+
chat.set_status_line_workspace_changes(cwd, stats);
371+
372+
assert_eq!(
373+
chat.status_line_workspace_changes_cwd.as_deref(),
374+
Some(expected_cwd.as_path())
375+
);
376+
assert_eq!(status_line_text(&chat), Some("+2/-1 ?1".to_string()));
377+
}
378+
314379
#[tokio::test]
315380
async fn raw_output_status_line_value_only_shows_when_enabled() {
316381
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1627,6 +1692,62 @@ fn install_noop_workspace_command_runner(chat: &mut ChatWidget) {
16271692
chat.workspace_command_runner = Some(std::sync::Arc::new(NoopWorkspaceCommandRunner));
16281693
}
16291694

1695+
fn install_scripted_workspace_command_runner(
1696+
chat: &mut ChatWidget,
1697+
responses: Vec<WorkspaceCommandResponse>,
1698+
) {
1699+
chat.workspace_command_runner = Some(std::sync::Arc::new(ScriptedWorkspaceCommandRunner {
1700+
responses: std::sync::Mutex::new(responses.into()),
1701+
}));
1702+
}
1703+
1704+
fn workspace_response(argv: &[&str], exit_code: i32, stdout: &str) -> WorkspaceCommandResponse {
1705+
WorkspaceCommandResponse {
1706+
argv: argv.iter().map(|arg| (*arg).to_string()).collect(),
1707+
output: crate::workspace_command::WorkspaceCommandOutput {
1708+
exit_code,
1709+
stdout: stdout.to_string(),
1710+
stderr: String::new(),
1711+
},
1712+
}
1713+
}
1714+
1715+
struct WorkspaceCommandResponse {
1716+
argv: Vec<String>,
1717+
output: crate::workspace_command::WorkspaceCommandOutput,
1718+
}
1719+
1720+
struct ScriptedWorkspaceCommandRunner {
1721+
responses: std::sync::Mutex<std::collections::VecDeque<WorkspaceCommandResponse>>,
1722+
}
1723+
1724+
impl crate::workspace_command::WorkspaceCommandExecutor for ScriptedWorkspaceCommandRunner {
1725+
fn run(
1726+
&self,
1727+
command: crate::workspace_command::WorkspaceCommand,
1728+
) -> std::pin::Pin<
1729+
Box<
1730+
dyn std::future::Future<
1731+
Output = Result<
1732+
crate::workspace_command::WorkspaceCommandOutput,
1733+
crate::workspace_command::WorkspaceCommandError,
1734+
>,
1735+
> + Send
1736+
+ '_,
1737+
>,
1738+
> {
1739+
Box::pin(async move {
1740+
let mut responses = self.responses.lock().expect("responses lock");
1741+
let index = responses
1742+
.iter()
1743+
.position(|response| response.argv == command.argv)
1744+
.unwrap_or_else(|| panic!("missing fake response for {:?}", command.argv));
1745+
let response = responses.remove(index).expect("fake response");
1746+
Ok(response.output)
1747+
})
1748+
}
1749+
}
1750+
16301751
struct NoopWorkspaceCommandRunner;
16311752

16321753
impl crate::workspace_command::WorkspaceCommandExecutor for NoopWorkspaceCommandRunner {

0 commit comments

Comments
 (0)