Skip to main content

git_hunk/
lib.rs

1pub mod cli;
2mod diff;
3mod error;
4mod git;
5mod model;
6mod patch;
7mod resolve;
8mod scan;
9mod select;
10mod validate;
11
12use std::io::Read;
13use std::path::PathBuf;
14
15use cli::{Cli, Command, CommitArgs, MutateArgs, ResolveArgs, ScanArgs, ShowArgs, ValidateArgs};
16use error::{AppError, AppResult};
17use model::{ChangeView, HunkView, ScanState, SelectionPlan, SnapshotOutput};
18use select::{HunkSelector, SelectionInput};
19use serde::Serialize;
20
21pub use error::AppError as Error;
22
23pub fn run(cli: Cli) -> AppResult<CommandOutput> {
24    let repo_root = git::repo_root(&std::env::current_dir().map_err(AppError::io)?)?;
25
26    match cli.command {
27        Command::Scan(args) => scan_command(&repo_root, args),
28        Command::Show(args) => show_command(&repo_root, args),
29        Command::Resolve(args) => resolve_command(&repo_root, args),
30        Command::Validate(args) => validate_command(&repo_root, args),
31        Command::Stage(args) => mutate_command(&repo_root, args, false),
32        Command::Unstage(args) => mutate_command(&repo_root, args, true),
33        Command::Commit(args) => commit_command(&repo_root, args),
34    }
35}
36
37fn scan_command(repo_root: &PathBuf, args: ScanArgs) -> AppResult<CommandOutput> {
38    let state = scan::scan_repo(repo_root, args.mode)?;
39    Ok(CommandOutput::Scan(SnapshotOutput::from_snapshot(
40        state.snapshot,
41        args.compact,
42    )))
43}
44
45fn show_command(repo_root: &PathBuf, args: ShowArgs) -> AppResult<CommandOutput> {
46    let state = scan::scan_repo(repo_root, args.mode)?;
47
48    if let Some((file, hunk)) = state.find_hunk(&args.id) {
49        return Ok(CommandOutput::Show(ShowResponse::Hunk {
50            snapshot_id: state.snapshot.snapshot_id.clone(),
51            mode: state.snapshot.mode,
52            path: file.path.clone(),
53            status: file.status,
54            hunk: hunk.clone(),
55        }));
56    }
57
58    if let Some((file, change)) = state.find_change(&args.id) {
59        return Ok(CommandOutput::Show(ShowResponse::Change {
60            snapshot_id: state.snapshot.snapshot_id.clone(),
61            mode: state.snapshot.mode,
62            path: file.path.clone(),
63            status: file.status,
64            change: change.clone(),
65        }));
66    }
67
68    if let Some((file, change)) = state.find_change_key(&args.id) {
69        return Ok(CommandOutput::Show(ShowResponse::Change {
70            snapshot_id: state.snapshot.snapshot_id.clone(),
71            mode: state.snapshot.mode,
72            path: file.path.clone(),
73            status: file.status,
74            change: change.clone(),
75        }));
76    }
77
78    Err(AppError::new(
79        "unknown_id",
80        format!("no hunk or change found for id '{}'", args.id),
81    ))
82}
83
84fn resolve_command(repo_root: &PathBuf, args: ResolveArgs) -> AppResult<CommandOutput> {
85    let selection = SelectionInput {
86        snapshot_id: Some(args.snapshot),
87        hunks: Vec::new(),
88        change_ids: Vec::new(),
89        change_keys: Vec::new(),
90    };
91    let state = validate_snapshot(repo_root, args.mode, &selection)?;
92    let response = resolve::resolve_region(
93        &state,
94        &args.path,
95        args.start,
96        args.end.unwrap_or(args.start),
97        args.side,
98    )?;
99    Ok(CommandOutput::Resolve(response))
100}
101
102fn validate_command(repo_root: &PathBuf, args: ValidateArgs) -> AppResult<CommandOutput> {
103    let selection = load_selection_input(
104        args.snapshot,
105        args.plan,
106        args.hunks,
107        args.changes,
108        args.change_keys,
109    )?;
110    let state = scan::scan_repo(repo_root, args.mode)?;
111    Ok(CommandOutput::Validate(validate::validate_selection(
112        &state,
113        &selection,
114        args.compact,
115    )))
116}
117
118fn mutate_command(
119    repo_root: &PathBuf,
120    args: MutateArgs,
121    reverse: bool,
122) -> AppResult<CommandOutput> {
123    let mode = if reverse {
124        cli::Mode::Unstage
125    } else {
126        cli::Mode::Stage
127    };
128    let selection = load_selection_input(
129        args.snapshot,
130        args.plan,
131        args.hunks,
132        args.changes,
133        args.change_keys,
134    )?;
135    let state = validate_snapshot(repo_root, mode, &selection)?;
136    let resolved = select::resolve_selection(&state, &selection)?;
137    let patch = patch::build_patch(&state, &resolved)?;
138
139    if args.dry_run {
140        let preview = git::preview_index(repo_root, Some(&patch), reverse)?;
141        return Ok(CommandOutput::MutationDryRun(MutationDryRunResponse {
142            action: if reverse { "unstage" } else { "stage" },
143            dry_run: true,
144            snapshot_id: state.snapshot.snapshot_id.clone(),
145            mode,
146            selected_hunks: resolved.selected_hunks,
147            selected_changes: resolved.selected_changes,
148            selected_change_keys: resolved.selected_change_keys,
149            selected_line_ranges: resolved.selected_line_ranges,
150            files: preview.files,
151            patch: preview.patch,
152            diffstat: preview.diffstat,
153        }));
154    }
155
156    git::apply_patch(repo_root, &patch, reverse)?;
157
158    let next_state = scan::scan_repo(repo_root, mode)?;
159    Ok(CommandOutput::Mutation(MutationResponse {
160        action: if reverse { "unstage" } else { "stage" },
161        snapshot_id: next_state.snapshot.snapshot_id.clone(),
162        mode,
163        selected_hunks: resolved.selected_hunks,
164        selected_changes: resolved.selected_changes,
165        selected_change_keys: resolved.selected_change_keys,
166        selected_line_ranges: resolved.selected_line_ranges,
167        snapshot: SnapshotOutput::from_snapshot(next_state.snapshot, args.compact),
168    }))
169}
170
171fn commit_command(repo_root: &PathBuf, args: CommitArgs) -> AppResult<CommandOutput> {
172    if args.messages.is_empty() {
173        return Err(AppError::new(
174            "missing_message",
175            "commit requires at least one message".to_string(),
176        ));
177    }
178
179    let selection = load_selection_input(
180        args.snapshot,
181        args.plan,
182        args.hunks,
183        args.changes,
184        args.change_keys,
185    )?;
186    let prepared = prepare_commit_selection(repo_root, &selection)?;
187
188    if args.dry_run {
189        let preview = git::preview_commit(repo_root, prepared.patch.as_deref(), args.allow_empty)?;
190        return Ok(CommandOutput::CommitDryRun(CommitDryRunResponse {
191            dry_run: true,
192            snapshot_id: prepared.snapshot_id,
193            messages: args.messages,
194            selected_hunks: prepared.selected_hunks,
195            selected_changes: prepared.selected_changes,
196            selected_change_keys: prepared.selected_change_keys,
197            selected_line_ranges: prepared.selected_line_ranges,
198            files: preview.files,
199            patch: preview.patch,
200            diffstat: preview.diffstat,
201        }));
202    }
203
204    if let Some(patch) = prepared.patch.as_deref() {
205        git::apply_patch(repo_root, patch, false)?;
206    }
207
208    if !args.allow_empty && !git::has_staged_changes(repo_root)? {
209        return Err(AppError::new(
210            "nothing_staged",
211            "there are no staged changes to commit".to_string(),
212        ));
213    }
214
215    let commit_sha = git::commit(repo_root, &args.messages, args.allow_empty)?;
216    let next_state = scan::scan_repo(repo_root, cli::Mode::Stage)?;
217
218    Ok(CommandOutput::Commit(CommitResponse {
219        commit: commit_sha,
220        snapshot_id: next_state.snapshot.snapshot_id.clone(),
221        selected_hunks: prepared.selected_hunks,
222        selected_changes: prepared.selected_changes,
223        selected_change_keys: prepared.selected_change_keys,
224        selected_line_ranges: prepared.selected_line_ranges,
225        snapshot: SnapshotOutput::from_snapshot(next_state.snapshot, args.compact),
226    }))
227}
228
229fn prepare_commit_selection(
230    repo_root: &PathBuf,
231    selection: &SelectionInput,
232) -> AppResult<PreparedCommitSelection> {
233    if selection.has_selectors() {
234        let state = validate_snapshot(repo_root, cli::Mode::Stage, selection)?;
235        let resolved = select::resolve_selection(&state, selection)?;
236        let patch = patch::build_patch(&state, &resolved)?;
237        return Ok(PreparedCommitSelection {
238            snapshot_id: state.snapshot.snapshot_id.clone(),
239            patch: Some(patch),
240            selected_hunks: resolved.selected_hunks,
241            selected_changes: resolved.selected_changes,
242            selected_change_keys: resolved.selected_change_keys,
243            selected_line_ranges: resolved.selected_line_ranges,
244        });
245    }
246
247    let state = scan::scan_repo(repo_root, cli::Mode::Stage)?;
248    if let Some(snapshot_id) = selection.snapshot_id.as_ref() {
249        if state.snapshot.snapshot_id != *snapshot_id {
250            return Err(stale_snapshot_error(
251                cli::Mode::Stage,
252                snapshot_id,
253                &state,
254                selection,
255            ));
256        }
257    }
258
259    Ok(PreparedCommitSelection {
260        snapshot_id: state.snapshot.snapshot_id,
261        patch: None,
262        selected_hunks: Vec::new(),
263        selected_changes: Vec::new(),
264        selected_change_keys: Vec::new(),
265        selected_line_ranges: Vec::new(),
266    })
267}
268
269fn validate_snapshot(
270    repo_root: &PathBuf,
271    mode: cli::Mode,
272    selection: &SelectionInput,
273) -> AppResult<ScanState> {
274    let snapshot_id = selection.snapshot_id.as_ref().ok_or_else(|| {
275        AppError::new(
276            "missing_snapshot",
277            "mutating commands require --snapshot or a plan with snapshot_id".to_string(),
278        )
279    })?;
280
281    let state = scan::scan_repo(repo_root, mode)?;
282    if state.snapshot.snapshot_id != *snapshot_id {
283        return Err(stale_snapshot_error(mode, snapshot_id, &state, selection));
284    }
285    Ok(state)
286}
287
288fn stale_snapshot_error(
289    mode: cli::Mode,
290    requested_snapshot: &str,
291    state: &ScanState,
292    selection: &SelectionInput,
293) -> AppError {
294    let validation = validate::summarize_selection(state, selection);
295    AppError::new(
296        "stale_snapshot",
297        format!(
298            "snapshot '{}' no longer matches the current {} view '{}'",
299            requested_snapshot,
300            mode.as_str(),
301            state.snapshot.snapshot_id.as_str()
302        ),
303    )
304    .with_details(serde_json::json!({
305        "mode": mode.as_str(),
306        "requested_snapshot_id": requested_snapshot,
307        "current_snapshot_id": state.snapshot.snapshot_id,
308        "snapshot_matches": validation.snapshot_matches,
309        "directly_usable": validation.directly_usable,
310        "can_apply": validation.can_apply,
311        "resolved_selectors": validation.resolved_selectors,
312        "unresolved_selectors": validation.unresolved_selectors,
313        "matched_changes": validation.matched_changes,
314    }))
315}
316
317fn load_selection_input(
318    snapshot: Option<String>,
319    plan_path: Option<PathBuf>,
320    hunks: Vec<String>,
321    changes: Vec<String>,
322    change_keys: Vec<String>,
323) -> AppResult<SelectionInput> {
324    let mut input = SelectionInput {
325        snapshot_id: snapshot,
326        hunks: hunks
327            .into_iter()
328            .map(|raw| HunkSelector::parse(&raw))
329            .collect::<AppResult<Vec<_>>>()?,
330        change_ids: changes,
331        change_keys,
332    };
333
334    if let Some(path) = plan_path {
335        let display = path.display().to_string();
336        let contents = if path == PathBuf::from("-") {
337            let mut contents = String::new();
338            std::io::stdin()
339                .read_to_string(&mut contents)
340                .map_err(|err| {
341                    AppError::new(
342                        "plan_read_failed",
343                        format!("failed to read {}: {}", display, err),
344                    )
345                })?;
346            contents
347        } else {
348            std::fs::read_to_string(&path).map_err(|err| {
349                AppError::new(
350                    "plan_read_failed",
351                    format!("failed to read {}: {}", display, err),
352                )
353            })?
354        };
355        let plan: SelectionPlan = serde_json::from_str(&contents).map_err(|err| {
356            AppError::new(
357                "plan_parse_failed",
358                format!("failed to parse {}: {}", display, err),
359            )
360        })?;
361
362        if input.snapshot_id.is_none() {
363            input.snapshot_id = Some(plan.snapshot_id);
364        }
365        for selector in plan.selectors {
366            match selector {
367                model::PlanSelector::Hunk { id } => input.hunks.push(HunkSelector::Whole { id }),
368                model::PlanSelector::Change { id } => input.change_ids.push(id),
369                model::PlanSelector::ChangeKey { key } => input.change_keys.push(key),
370                model::PlanSelector::LineRange {
371                    hunk_id,
372                    side,
373                    start,
374                    end,
375                } => input
376                    .hunks
377                    .push(select::HunkSelector::LineRange(select::LineRangeSelector {
378                        hunk_id,
379                        side,
380                        start,
381                        end,
382                    })),
383            }
384        }
385    }
386
387    Ok(input)
388}
389
390struct PreparedCommitSelection {
391    snapshot_id: String,
392    patch: Option<String>,
393    selected_hunks: Vec<String>,
394    selected_changes: Vec<String>,
395    selected_change_keys: Vec<String>,
396    selected_line_ranges: Vec<String>,
397}
398
399#[derive(Debug)]
400pub enum CommandOutput {
401    Scan(SnapshotOutput),
402    Show(ShowResponse),
403    Resolve(resolve::ResolveResponse),
404    Validate(validate::ValidateResponse),
405    Mutation(MutationResponse),
406    MutationDryRun(MutationDryRunResponse),
407    Commit(CommitResponse),
408    CommitDryRun(CommitDryRunResponse),
409}
410
411impl CommandOutput {
412    pub fn to_json_string(&self) -> String {
413        serde_json::to_string_pretty(self).expect("command output should serialize")
414    }
415
416    pub fn to_text(&self) -> String {
417        match self {
418            CommandOutput::Scan(snapshot) => snapshot.to_text(),
419            CommandOutput::Show(show) => show.to_text(),
420            CommandOutput::Resolve(response) => response.to_text(),
421            CommandOutput::Validate(response) => response.to_text(),
422            CommandOutput::Mutation(response) => response.to_text(),
423            CommandOutput::MutationDryRun(response) => response.to_text(),
424            CommandOutput::Commit(response) => response.to_text(),
425            CommandOutput::CommitDryRun(response) => response.to_text(),
426        }
427    }
428}
429
430impl Serialize for CommandOutput {
431    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
432    where
433        S: serde::Serializer,
434    {
435        match self {
436            CommandOutput::Scan(snapshot) => snapshot.serialize(serializer),
437            CommandOutput::Show(show) => show.serialize(serializer),
438            CommandOutput::Resolve(response) => response.serialize(serializer),
439            CommandOutput::Validate(response) => response.serialize(serializer),
440            CommandOutput::Mutation(response) => response.serialize(serializer),
441            CommandOutput::MutationDryRun(response) => response.serialize(serializer),
442            CommandOutput::Commit(response) => response.serialize(serializer),
443            CommandOutput::CommitDryRun(response) => response.serialize(serializer),
444        }
445    }
446}
447
448#[derive(Debug, Serialize)]
449#[serde(tag = "kind", rename_all = "snake_case")]
450pub enum ShowResponse {
451    Hunk {
452        snapshot_id: String,
453        mode: cli::Mode,
454        path: String,
455        status: model::FileStatus,
456        hunk: HunkView,
457    },
458    Change {
459        snapshot_id: String,
460        mode: cli::Mode,
461        path: String,
462        status: model::FileStatus,
463        change: ChangeView,
464    },
465}
466
467impl ShowResponse {
468    fn to_text(&self) -> String {
469        match self {
470            ShowResponse::Hunk { path, hunk, .. } => {
471                let mut out = format!("{} {}\n", path, hunk.id);
472                out.push_str(&format!("{}\n", hunk.header));
473                for line in &hunk.lines {
474                    out.push_str(&format!("{}\n", render_numbered_line(line)));
475                }
476                out.trim_end().to_string()
477            }
478            ShowResponse::Change { path, change, .. } => {
479                let mut out = format!("{} {}\n", path, change.id);
480                out.push_str(&format!(
481                    "{} ({}) [{} +{} -{} {}]\n",
482                    change.header,
483                    change.change_key,
484                    change.metadata.kind.as_str(),
485                    change.metadata.added_lines,
486                    change.metadata.deleted_lines,
487                    change.metadata.preview
488                ));
489                for line in &change.lines {
490                    out.push_str(&format!("{}\n", render_numbered_line(line)));
491                }
492                out.trim_end().to_string()
493            }
494        }
495    }
496}
497
498fn render_numbered_line(line: &model::DiffLineView) -> String {
499    let old = line
500        .old_lineno
501        .map(|value| value.to_string())
502        .unwrap_or_else(|| "-".to_string());
503    let new = line
504        .new_lineno
505        .map(|value| value.to_string())
506        .unwrap_or_else(|| "-".to_string());
507    format!("{:>4} {:>4} {}", old, new, line.render())
508}
509
510#[derive(Debug, Serialize)]
511pub struct MutationResponse {
512    pub action: &'static str,
513    pub snapshot_id: String,
514    pub mode: cli::Mode,
515    pub selected_hunks: Vec<String>,
516    pub selected_changes: Vec<String>,
517    pub selected_change_keys: Vec<String>,
518    pub selected_line_ranges: Vec<String>,
519    pub snapshot: SnapshotOutput,
520}
521
522impl MutationResponse {
523    fn to_text(&self) -> String {
524        format!(
525            "{}d {} hunks, {} changes, {} change keys, and {} line ranges\nnext snapshot: {}",
526            self.action,
527            self.selected_hunks.len(),
528            self.selected_changes.len(),
529            self.selected_change_keys.len(),
530            self.selected_line_ranges.len(),
531            self.snapshot_id
532        )
533    }
534}
535
536#[derive(Debug, Serialize)]
537pub struct MutationDryRunResponse {
538    pub action: &'static str,
539    pub dry_run: bool,
540    pub snapshot_id: String,
541    pub mode: cli::Mode,
542    pub selected_hunks: Vec<String>,
543    pub selected_changes: Vec<String>,
544    pub selected_change_keys: Vec<String>,
545    pub selected_line_ranges: Vec<String>,
546    pub files: Vec<String>,
547    pub patch: String,
548    pub diffstat: String,
549}
550
551impl MutationDryRunResponse {
552    fn to_text(&self) -> String {
553        format!(
554            "would {} {} files using {} hunks, {} changes, {} change keys, and {} line ranges\nsnapshot: {}",
555            self.action,
556            self.files.len(),
557            self.selected_hunks.len(),
558            self.selected_changes.len(),
559            self.selected_change_keys.len(),
560            self.selected_line_ranges.len(),
561            self.snapshot_id
562        )
563    }
564}
565
566#[derive(Debug, Serialize)]
567pub struct CommitResponse {
568    pub commit: String,
569    pub snapshot_id: String,
570    pub selected_hunks: Vec<String>,
571    pub selected_changes: Vec<String>,
572    pub selected_change_keys: Vec<String>,
573    pub selected_line_ranges: Vec<String>,
574    pub snapshot: SnapshotOutput,
575}
576
577impl CommitResponse {
578    fn to_text(&self) -> String {
579        format!(
580            "committed {} using {} hunks, {} changes, {} change keys, and {} line ranges\nnext snapshot: {}",
581            self.commit,
582            self.selected_hunks.len(),
583            self.selected_changes.len(),
584            self.selected_change_keys.len(),
585            self.selected_line_ranges.len(),
586            self.snapshot_id
587        )
588    }
589}
590
591#[derive(Debug, Serialize)]
592pub struct CommitDryRunResponse {
593    pub dry_run: bool,
594    pub snapshot_id: String,
595    pub messages: Vec<String>,
596    pub selected_hunks: Vec<String>,
597    pub selected_changes: Vec<String>,
598    pub selected_change_keys: Vec<String>,
599    pub selected_line_ranges: Vec<String>,
600    pub files: Vec<String>,
601    pub patch: String,
602    pub diffstat: String,
603}
604
605impl CommitDryRunResponse {
606    fn to_text(&self) -> String {
607        format!(
608            "would commit {} files using {} hunks, {} changes, {} change keys, and {} line ranges\nsnapshot: {}",
609            self.files.len(),
610            self.selected_hunks.len(),
611            self.selected_changes.len(),
612            self.selected_change_keys.len(),
613            self.selected_line_ranges.len(),
614            self.snapshot_id
615        )
616    }
617}