feat(cli): kebab ingest progress display (p9-fb-02) + p9-fb-01 status flip

`kebab ingest` 가 진행 상황을 사용자에게 보여주는 두 surface 추가:

- **사람 모드 (TTY)**: indicatif `ProgressBar` on stderr — scan 중에는
  spinner, ScanCompleted 후 bar 로 전환, 매 asset 마다 message 갱신.
- **사람 모드 (non-TTY, CI/pipe)**: indicatif draw target 을 hidden
  으로 두고 stderr 에 한 줄씩 (`ingest: scanning`, `ingest: 1/N path`,
  `ingest: complete (...)`).
- **`--json` 모드**: stderr 비우고 stdout 에 line-delimited
  `ingest_progress.v1` JSON 을 emit. 마지막 줄은 기존
  `ingest_report.v1` 그대로 (외부 wrapper backward-compat).

구현:

- 신규 `crates/kebab-cli/src/progress.rs` — `ProgressMode::{Json,
  Human { tty }}`, `ProgressDisplay` (background thread 가 channel
  drain + 모드별 render), `now_rfc3339` helper. mode 가 무엇이든 ts
  는 wire emit 시점에 stamp.
- `crates/kebab-cli/src/wire.rs` 에 `wire_ingest_progress` 추가.
  serde tag (`kind`) 위에 `schema_version` + `ts` 두 필드 더해 spec
  §2.4a wire shape 완성.
- `Cmd::Ingest` 핸들러: mpsc channel 만들고 background thread 가
  display 돌리는 동안 main 이 `ingest_with_config_progress` 호출.
  ingest 반환 시 Sender drop → display thread 정상 종료. join 후
  최종 ingest_report 출력.
- 새 dep: `indicatif` 0.17 (TTY 전용 진행 바, non-TTY/--json 에서는
  hidden draw target).

Test:

- 3 lib unit (mode resolution + RFC 3339 round-trip).
- 3 integration (--json line-delimited / non-TTY stderr text /
  ts+kind 검증). 16 PASS 전체 회귀 0.

Plan 갱신:

- p9-fb-01: status `in_progress` → `completed` (PR #52 머지 후속).
- p9-fb-02: status `planned` → `in_progress`. 머지 후 별도 한 줄
  commit 으로 `completed` flip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 19:57:02 +00:00
parent 7c6009f7e7
commit e613236d60
10 changed files with 453 additions and 5 deletions

View File

@@ -8,6 +8,7 @@ use clap::{Parser, Subcommand};
use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
mod progress;
mod wire;
#[derive(Parser, Debug)]
@@ -283,7 +284,31 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
include: cfg.workspace.include.clone(),
exclude: cfg.workspace.exclude.clone(),
};
let report = kebab_app::ingest_with_config(cfg, scope, *summary_only)?;
// p9-fb-02: spawn the progress display on a background
// thread; the ingest call below holds the `Sender` end of
// the channel and emits per-step events into it. When the
// call returns, the `Sender` drops and the display thread
// sees `recv()` return Err — exits cleanly.
let mode = progress::ProgressMode::from_flags(cli.json);
let (tx, rx) = std::sync::mpsc::channel::<kebab_app::IngestEvent>();
let display_handle = std::thread::spawn(move || {
progress::ProgressDisplay::new(mode).run(rx)
});
let ingest_result = kebab_app::ingest_with_config_progress(
cfg,
scope,
*summary_only,
Some(tx),
);
// Join the display thread *before* surfacing the ingest
// outcome so the spinner / final newline is flushed
// regardless of whether ingest returned Ok or Err.
let _ = display_handle.join();
let report = ingest_result?;
if cli.json {
println!("{}", serde_json::to_string(&wire::wire_ingest(&report))?);
} else {