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