1use crate::config::Config;
2use crate::core::effect::{Effect, PlannedRefEdit};
3use crate::core::{ComponentMatcher, Error, GitError};
4use crate::git::branch::{BranchManager, BranchState};
5use crate::git::commit;
6use gix::ObjectId;
7use std::collections::{BTreeMap, HashSet};
8use std::path::PathBuf;
9
10#[derive(Debug)]
12pub struct AtomicResult {
13 pub component: String,
14 pub branch: String,
15 pub commit_id: ObjectId,
16 pub files: Vec<PathBuf>,
17 pub created: bool,
18}
19
20pub fn plan_atomize(
26 repo: &gix::Repository,
27 config: &Config,
28 matcher: &ComponentMatcher,
29 source_id: ObjectId,
30 force: bool,
31) -> Result<(Vec<AtomicResult>, Vec<Effect>), Error> {
32 let source_commit = repo
33 .find_commit(source_id)
34 .map_err(|e| GitError::Operation(format!("find source commit: {e}")))?;
35 let source_tree = source_commit
36 .tree()
37 .map_err(|e| GitError::Operation(format!("source tree: {e}")))?;
38 let source_author = source_commit
39 .author()
40 .map_err(|e| GitError::Operation(format!("source author: {e}")))?;
41 let source_summary = extract_summary(&source_commit);
42
43 let files = crate::git::changed_files(repo, source_id)?;
44 let path_refs: Vec<&std::path::Path> = files.iter().map(|p| p.as_path()).collect();
45 let (grouped, unmatched) = matcher.group_files(&path_refs);
46
47 if !unmatched.is_empty() {
49 handle_unmatched(config, &unmatched)?;
50 }
51
52 let base_id = crate::git::resolve_commit(repo, &config.settings.base_branch)?;
54 let branch_mgr = BranchManager::new(repo, base_id, config.settings.branch_template.clone());
55
56 let mut planned_edits: Vec<PlannedRefEdit> = Vec::new();
58 let mut results: Vec<AtomicResult> = Vec::new();
59
60 for (component_name, component_files) in &grouped {
61 let component_config = config.components.iter().find(|c| c.name == *component_name);
62 let branch_override = component_config.and_then(|c| c.branch.as_deref());
63 let ref_name = branch_mgr.branch_ref_name(component_name, branch_override);
64
65 let commit_type = component_config
66 .and_then(|c| c.commit_type.as_deref())
67 .or(config.settings.default_commit_type.as_deref())
68 .unwrap_or("feat");
69
70 let state = branch_mgr.check_state(&ref_name)?;
71 let parent_id =
72 branch_mgr
73 .parent_for(&ref_name, force)
74 .map_err(|_| Error::DivergedBranch {
75 branch: ref_name.clone(),
76 base: config.settings.base_branch.clone(),
77 })?;
78
79 let created = matches!(state, BranchState::Missing);
80 let previous = match &state {
81 BranchState::Missing => None,
82 BranchState::Current => Some(base_id),
83 BranchState::FastForward { tip } | BranchState::Diverged { tip } => Some(*tip),
84 };
85
86 let file_refs: Vec<&std::path::Path> = component_files.iter().map(|p| p.as_ref()).collect();
87 let tree_id = commit::build_partial_tree(repo, &source_tree, &file_refs)?;
88
89 let message = commit::generate_message(component_name, commit_type, &source_summary);
90 let commit_id = commit::create_commit(repo, tree_id, parent_id, &message, source_author)?;
91
92 planned_edits.push(PlannedRefEdit {
93 ref_name: ref_name.clone(),
94 new_id: commit_id,
95 previous,
96 component: component_name.to_string(),
97 created,
98 });
99
100 let branch_display = ref_name
101 .strip_prefix("refs/heads/")
102 .unwrap_or(&ref_name)
103 .to_string();
104
105 results.push(AtomicResult {
106 component: component_name.to_string(),
107 branch: branch_display,
108 commit_id,
109 files: component_files.iter().map(|p| p.to_path_buf()).collect(),
110 created,
111 });
112 }
113
114 let mut effects = Vec::new();
115 if !planned_edits.is_empty() {
116 let repo_path = repo.path().parent().unwrap_or(repo.path()).to_path_buf();
117 effects.push(Effect::RefTransaction {
118 repo_path,
119 edits: planned_edits,
120 });
121 }
122
123 Ok((results, effects))
124}
125
126pub fn plan_atomize_range(
133 repo: &gix::Repository,
134 config: &Config,
135 matcher: &ComponentMatcher,
136 commits: &[ObjectId],
137 effective_files: &HashSet<PathBuf>,
138 force: bool,
139) -> Result<(Vec<AtomicResult>, Vec<Effect>), Error> {
140 if commits.is_empty() {
141 return Ok((Vec::new(), Vec::new()));
142 }
143
144 let base_id = crate::git::resolve_commit(repo, &config.settings.base_branch)?;
145 let branch_mgr = BranchManager::new(repo, base_id, config.settings.branch_template.clone());
146
147 struct ComponentState {
152 cumulative_files: Vec<PathBuf>,
153 parent_id: ObjectId,
154 ref_name: String,
155 branch_display: String,
156 created: bool,
157 original_previous: Option<ObjectId>,
159 latest_commit_id: ObjectId,
161 all_files: Vec<PathBuf>,
163 }
164
165 let mut component_states: BTreeMap<String, ComponentState> = BTreeMap::new();
166 let mut all_results: Vec<AtomicResult> = Vec::new();
167
168 for &commit_id in commits {
169 let changed = crate::git::changed_files(repo, commit_id)?;
170 let effective_changed: Vec<PathBuf> = changed
172 .into_iter()
173 .filter(|p| effective_files.contains(p))
174 .collect();
175
176 if effective_changed.is_empty() {
177 continue;
178 }
179
180 let source_commit = repo
181 .find_commit(commit_id)
182 .map_err(|e| GitError::Operation(format!("find commit: {e}")))?;
183 let source_tree = source_commit
184 .tree()
185 .map_err(|e| GitError::Operation(format!("source tree: {e}")))?;
186 let source_author = source_commit
187 .author()
188 .map_err(|e| GitError::Operation(format!("source author: {e}")))?;
189 let source_summary = extract_summary(&source_commit);
190
191 let path_refs: Vec<&std::path::Path> =
193 effective_changed.iter().map(|p| p.as_path()).collect();
194 let (grouped, unmatched) = matcher.group_files(&path_refs);
195
196 if !unmatched.is_empty() {
197 handle_unmatched(config, &unmatched)?;
198 }
199
200 for (component_name, component_files) in &grouped {
201 let component_config = config.components.iter().find(|c| c.name == *component_name);
202 let commit_type = component_config
203 .and_then(|c| c.commit_type.as_deref())
204 .or(config.settings.default_commit_type.as_deref())
205 .unwrap_or("feat");
206
207 let state = component_states.get_mut(component_name as &str);
208
209 match state {
210 Some(cs) => {
211 for f in component_files {
213 let pb = f.to_path_buf();
214 if !cs.cumulative_files.contains(&pb) {
215 cs.cumulative_files.push(pb.clone());
216 }
217 if !cs.all_files.contains(&pb) {
218 cs.all_files.push(pb);
219 }
220 }
221
222 let file_refs: Vec<&std::path::Path> =
224 cs.cumulative_files.iter().map(|p| p.as_path()).collect();
225 let tree_id = commit::build_partial_tree(repo, &source_tree, &file_refs)?;
226
227 let message =
228 commit::generate_message(component_name, commit_type, &source_summary);
229 let new_commit_id = commit::create_commit(
230 repo,
231 tree_id,
232 cs.parent_id,
233 &message,
234 source_author,
235 )?;
236
237 cs.parent_id = new_commit_id;
238 cs.latest_commit_id = new_commit_id;
239 }
240 None => {
241 let branch_override = component_config.and_then(|c| c.branch.as_deref());
243 let ref_name = branch_mgr.branch_ref_name(component_name, branch_override);
244
245 let branch_state = branch_mgr.check_state(&ref_name)?;
246 let parent_id = branch_mgr.parent_for(&ref_name, force).map_err(|_| {
247 Error::DivergedBranch {
248 branch: ref_name.clone(),
249 base: config.settings.base_branch.clone(),
250 }
251 })?;
252
253 let created = matches!(branch_state, BranchState::Missing);
254 let original_previous = match &branch_state {
255 BranchState::Missing => None,
256 BranchState::Current => Some(base_id),
257 BranchState::FastForward { tip } | BranchState::Diverged { tip } => {
258 Some(*tip)
259 }
260 };
261
262 let cumulative_files: Vec<PathBuf> =
263 component_files.iter().map(|p| p.to_path_buf()).collect();
264 let file_refs: Vec<&std::path::Path> =
265 cumulative_files.iter().map(|p| p.as_path()).collect();
266 let tree_id = commit::build_partial_tree(repo, &source_tree, &file_refs)?;
267
268 let message =
269 commit::generate_message(component_name, commit_type, &source_summary);
270 let new_commit_id =
271 commit::create_commit(repo, tree_id, parent_id, &message, source_author)?;
272
273 let branch_display = ref_name
274 .strip_prefix("refs/heads/")
275 .unwrap_or(&ref_name)
276 .to_string();
277
278 component_states.insert(
279 component_name.to_string(),
280 ComponentState {
281 cumulative_files: cumulative_files.clone(),
282 parent_id: new_commit_id,
283 ref_name,
284 branch_display,
285 created,
286 original_previous,
287 latest_commit_id: new_commit_id,
288 all_files: cumulative_files,
289 },
290 );
291 }
292 }
293 }
294 }
295
296 let mut planned_edits: Vec<PlannedRefEdit> = Vec::new();
298
299 for (name, cs) in &component_states {
300 planned_edits.push(PlannedRefEdit {
301 ref_name: cs.ref_name.clone(),
302 new_id: cs.latest_commit_id,
303 previous: cs.original_previous,
304 component: name.clone(),
305 created: cs.created,
306 });
307
308 all_results.push(AtomicResult {
309 component: name.clone(),
310 branch: cs.branch_display.clone(),
311 commit_id: cs.latest_commit_id,
312 files: cs.all_files.clone(),
313 created: cs.created,
314 });
315 }
316
317 let mut effects = Vec::new();
318 if !planned_edits.is_empty() {
319 let repo_path = repo.path().parent().unwrap_or(repo.path()).to_path_buf();
320 effects.push(Effect::RefTransaction {
321 repo_path,
322 edits: planned_edits,
323 });
324 }
325
326 Ok((all_results, effects))
327}
328
329fn handle_unmatched(config: &Config, unmatched: &[&std::path::Path]) -> Result<(), Error> {
331 match config.settings.unmatched_files {
332 crate::config::UnmatchedPolicy::Error => Err(Error::UnmatchedFiles {
333 paths: unmatched.iter().map(|p| p.to_path_buf()).collect(),
334 }),
335 crate::config::UnmatchedPolicy::Warn => {
336 eprintln!(
337 "warning: unmatched files: {}",
338 unmatched
339 .iter()
340 .map(|p| p.display().to_string())
341 .collect::<Vec<_>>()
342 .join(", ")
343 );
344 Ok(())
345 }
346 crate::config::UnmatchedPolicy::Ignore => Ok(()),
347 }
348}
349
350fn extract_summary(commit: &gix::Commit<'_>) -> String {
352 let msg = commit.message_raw_sloppy();
353 let msg_str = String::from_utf8_lossy(msg.as_ref());
354 msg_str.lines().next().unwrap_or("commit").to_string()
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::config::Config;
361 use crate::core::ComponentMatcher;
362 use std::path::Path;
363 use std::process::Command;
364
365 fn git(dir: &Path, args: &[&str]) -> String {
366 let out = Command::new("git")
367 .args(args)
368 .current_dir(dir)
369 .output()
370 .unwrap();
371 String::from_utf8_lossy(&out.stdout).trim().to_string()
372 }
373
374 fn setup_multi_component_repo(dir: &Path) {
375 git(dir, &["init", "-b", "main"]);
376 git(dir, &["config", "user.email", "test@test.com"]);
377 git(dir, &["config", "user.name", "Test"]);
378
379 git(dir, &["commit", "--allow-empty", "-m", "initial"]);
381
382 std::fs::create_dir_all(dir.join("src/ui")).unwrap();
384 std::fs::create_dir_all(dir.join("src/api")).unwrap();
385 std::fs::write(dir.join("src/ui/app.ts"), "// frontend").unwrap();
386 std::fs::write(dir.join("src/api/handler.rs"), "// backend").unwrap();
387 git(dir, &["add", "."]);
388 git(dir, &["commit", "-m", "add components"]);
389 }
390
391 fn test_config() -> Config {
392 let toml_str = r#"
393[settings]
394base_branch = "main"
395unmatched_files = "ignore"
396
397[[components]]
398name = "frontend"
399globs = ["src/ui/**"]
400
401[[components]]
402name = "backend"
403globs = ["src/api/**"]
404commit_type = "fix"
405"#;
406 toml::from_str(toml_str).unwrap()
407 }
408
409 #[test]
410 fn plan_atomize_returns_effects_without_mutating() {
411 let dir = tempfile::tempdir().unwrap();
412 setup_multi_component_repo(dir.path());
413
414 let repo = crate::git::open_repo(dir.path()).unwrap();
415 let config = test_config();
416 let matcher = ComponentMatcher::from_config(&config).unwrap();
417 let head = crate::git::resolve_commit(&repo, "HEAD").unwrap();
418
419 let (results, effects) = plan_atomize(&repo, &config, &matcher, head, false).unwrap();
420
421 assert_eq!(results.len(), 2);
422 assert_eq!(effects.len(), 1);
423
424 for result in &results {
425 let ref_name = format!("refs/heads/{}", result.branch);
426 let reference = repo.try_find_reference(&ref_name).unwrap();
427 assert!(
428 reference.is_none(),
429 "branch {} should not exist yet",
430 result.branch
431 );
432 }
433 }
434
435 #[test]
436 fn atomize_creates_branches() {
437 let dir = tempfile::tempdir().unwrap();
438 setup_multi_component_repo(dir.path());
439
440 let repo = crate::git::open_repo(dir.path()).unwrap();
441 let config = test_config();
442 let matcher = ComponentMatcher::from_config(&config).unwrap();
443 let head = crate::git::resolve_commit(&repo, "HEAD").unwrap();
444
445 let (results, effects) = plan_atomize(&repo, &config, &matcher, head, false).unwrap();
446
447 let printer = crate::cli::output::Printer::new(false, true, 0);
448 crate::core::effect::execute(Some(&repo), &effects, false, &printer).unwrap();
449
450 assert_eq!(results.len(), 2);
451
452 let names: Vec<&str> = results.iter().map(|r| r.component.as_str()).collect();
453 assert!(names.contains(&"frontend"));
454 assert!(names.contains(&"backend"));
455
456 for result in &results {
457 assert!(result.created);
458 let ref_name = format!("refs/heads/{}", result.branch);
459 let reference = repo.try_find_reference(&ref_name).unwrap();
460 assert!(reference.is_some(), "branch {} should exist", result.branch);
461 }
462
463 for result in &results {
464 let c = repo.find_commit(result.commit_id).unwrap();
465 let msg = c.message_raw_sloppy().to_string();
466 if result.component == "frontend" {
467 assert!(msg.starts_with("feat(frontend):"), "got: {msg}");
468 } else {
469 assert!(msg.starts_with("fix(backend):"), "got: {msg}");
470 }
471 }
472 }
473
474 #[test]
475 fn atomize_partial_trees_are_isolated() {
476 let dir = tempfile::tempdir().unwrap();
477 setup_multi_component_repo(dir.path());
478
479 let repo = crate::git::open_repo(dir.path()).unwrap();
480 let config = test_config();
481 let matcher = ComponentMatcher::from_config(&config).unwrap();
482 let head = crate::git::resolve_commit(&repo, "HEAD").unwrap();
483
484 let (results, effects) = plan_atomize(&repo, &config, &matcher, head, false).unwrap();
485
486 let printer = crate::cli::output::Printer::new(false, true, 0);
487 crate::core::effect::execute(Some(&repo), &effects, false, &printer).unwrap();
488
489 for result in &results {
490 let c = repo.find_commit(result.commit_id).unwrap();
491 let tree = c.tree().unwrap();
492
493 if result.component == "frontend" {
494 assert!(
495 tree.lookup_entry_by_path("src/ui/app.ts")
496 .unwrap()
497 .is_some()
498 );
499 assert!(
500 tree.lookup_entry_by_path("src/api/handler.rs")
501 .unwrap()
502 .is_none()
503 );
504 } else {
505 assert!(
506 tree.lookup_entry_by_path("src/api/handler.rs")
507 .unwrap()
508 .is_some()
509 );
510 assert!(
511 tree.lookup_entry_by_path("src/ui/app.ts")
512 .unwrap()
513 .is_none()
514 );
515 }
516 }
517 }
518
519 #[test]
520 fn range_filters_net_zero_files() {
521 let dir = tempfile::tempdir().unwrap();
522 git(dir.path(), &["init", "-b", "main"]);
523 git(dir.path(), &["config", "user.email", "test@test.com"]);
524 git(dir.path(), &["config", "user.name", "Test"]);
525 git(dir.path(), &["commit", "--allow-empty", "-m", "initial"]);
526
527 let base = git(dir.path(), &["rev-parse", "HEAD"]);
528
529 std::fs::create_dir_all(dir.path().join("src/ui")).unwrap();
531 std::fs::write(dir.path().join("src/ui/foo.ts"), "foo").unwrap();
532 std::fs::write(dir.path().join("src/ui/bar.ts"), "bar").unwrap();
533 git(dir.path(), &["add", "."]);
534 git(dir.path(), &["commit", "-m", "feat: initial UI"]);
535
536 std::fs::write(dir.path().join("src/ui/foo.ts"), "foo modified").unwrap();
538 git(dir.path(), &["add", "."]);
539 git(dir.path(), &["commit", "-m", "fix: layout bug"]);
540
541 std::fs::remove_file(dir.path().join("src/ui/foo.ts")).unwrap();
543 git(dir.path(), &["add", "."]);
544 git(dir.path(), &["commit", "-m", "refactor: remove foo"]);
545
546 let repo = crate::git::open_repo(dir.path()).unwrap();
547 let start_id = crate::git::resolve_commit(&repo, &base).unwrap();
548 let end_id = crate::git::resolve_commit(&repo, "HEAD").unwrap();
549
550 let commits = crate::git::walk::walk_range(&repo, start_id, end_id).unwrap();
551 let effective = crate::git::walk::effective_files(&repo, start_id, end_id).unwrap();
552
553 assert!(effective.contains(&PathBuf::from("src/ui/bar.ts")));
555 assert!(!effective.contains(&PathBuf::from("src/ui/foo.ts")));
556
557 let config_str = r#"
558[settings]
559base_branch = "main"
560unmatched_files = "ignore"
561
562[[components]]
563name = "frontend"
564globs = ["src/ui/**"]
565"#;
566 let config: Config = toml::from_str(config_str).unwrap();
567 let matcher = ComponentMatcher::from_config(&config).unwrap();
568
569 let (results, effects) =
570 plan_atomize_range(&repo, &config, &matcher, &commits, &effective, false).unwrap();
571
572 assert_eq!(results.len(), 1);
574 assert_eq!(results[0].component, "frontend");
575 assert_eq!(results[0].files, vec![PathBuf::from("src/ui/bar.ts")]);
577
578 let printer = crate::cli::output::Printer::new(false, true, 0);
580 crate::core::effect::execute(Some(&repo), &effects, false, &printer).unwrap();
581
582 let c = repo.find_commit(results[0].commit_id).unwrap();
583 let tree = c.tree().unwrap();
584 assert!(
585 tree.lookup_entry_by_path("src/ui/bar.ts")
586 .unwrap()
587 .is_some()
588 );
589 assert!(
590 tree.lookup_entry_by_path("src/ui/foo.ts")
591 .unwrap()
592 .is_none()
593 );
594 }
595
596 #[test]
597 fn range_incremental_trees() {
598 let dir = tempfile::tempdir().unwrap();
599 git(dir.path(), &["init", "-b", "main"]);
600 git(dir.path(), &["config", "user.email", "test@test.com"]);
601 git(dir.path(), &["config", "user.name", "Test"]);
602 git(dir.path(), &["commit", "--allow-empty", "-m", "initial"]);
603
604 let base = git(dir.path(), &["rev-parse", "HEAD"]);
605
606 std::fs::create_dir_all(dir.path().join("src/ui")).unwrap();
608 std::fs::write(dir.path().join("src/ui/bar.ts"), "bar").unwrap();
609 git(dir.path(), &["add", "."]);
610 git(dir.path(), &["commit", "-m", "feat: add bar"]);
611
612 std::fs::write(dir.path().join("src/ui/baz.ts"), "baz").unwrap();
614 git(dir.path(), &["add", "."]);
615 git(dir.path(), &["commit", "-m", "feat: add baz"]);
616
617 std::fs::write(dir.path().join("src/ui/baz.ts"), "baz modified").unwrap();
619 git(dir.path(), &["add", "."]);
620 git(dir.path(), &["commit", "-m", "fix: update baz"]);
621
622 let repo = crate::git::open_repo(dir.path()).unwrap();
623 let start_id = crate::git::resolve_commit(&repo, &base).unwrap();
624 let end_id = crate::git::resolve_commit(&repo, "HEAD").unwrap();
625
626 let commits = crate::git::walk::walk_range(&repo, start_id, end_id).unwrap();
627 let effective = crate::git::walk::effective_files(&repo, start_id, end_id).unwrap();
628
629 assert_eq!(commits.len(), 3);
630 assert_eq!(effective.len(), 4);
632
633 let config_str = r#"
634[settings]
635base_branch = "main"
636unmatched_files = "ignore"
637
638[[components]]
639name = "frontend"
640globs = ["src/ui/**"]
641"#;
642 let config: Config = toml::from_str(config_str).unwrap();
643 let matcher = ComponentMatcher::from_config(&config).unwrap();
644
645 let (results, effects) =
646 plan_atomize_range(&repo, &config, &matcher, &commits, &effective, false).unwrap();
647
648 let printer = crate::cli::output::Printer::new(false, true, 0);
649 crate::core::effect::execute(Some(&repo), &effects, false, &printer).unwrap();
650
651 assert_eq!(results.len(), 1);
652 let final_commit = repo.find_commit(results[0].commit_id).unwrap();
653 let final_tree = final_commit.tree().unwrap();
654
655 assert!(
657 final_tree
658 .lookup_entry_by_path("src/ui/bar.ts")
659 .unwrap()
660 .is_some()
661 );
662 assert!(
663 final_tree
664 .lookup_entry_by_path("src/ui/baz.ts")
665 .unwrap()
666 .is_some()
667 );
668
669 let parent_id = final_commit.parent_ids().next().unwrap().detach();
671 let parent_commit = repo.find_commit(parent_id).unwrap();
672 let parent_tree = parent_commit.tree().unwrap();
673 assert!(
674 parent_tree
675 .lookup_entry_by_path("src/ui/bar.ts")
676 .unwrap()
677 .is_some()
678 );
679 assert!(
680 parent_tree
681 .lookup_entry_by_path("src/ui/baz.ts")
682 .unwrap()
683 .is_some()
684 );
685
686 let grandparent_id = parent_commit.parent_ids().next().unwrap().detach();
688 let grandparent_commit = repo.find_commit(grandparent_id).unwrap();
689 let grandparent_tree = grandparent_commit.tree().unwrap();
690 assert!(
691 grandparent_tree
692 .lookup_entry_by_path("src/ui/bar.ts")
693 .unwrap()
694 .is_some()
695 );
696 assert!(
697 grandparent_tree
698 .lookup_entry_by_path("src/ui/baz.ts")
699 .unwrap()
700 .is_none()
701 );
702 }
703
704 #[test]
705 fn range_empty_produces_no_results() {
706 let dir = tempfile::tempdir().unwrap();
707 git(dir.path(), &["init", "-b", "main"]);
708 git(dir.path(), &["config", "user.email", "test@test.com"]);
709 git(dir.path(), &["config", "user.name", "Test"]);
710 git(dir.path(), &["commit", "--allow-empty", "-m", "initial"]);
711
712 let config_str = r#"
713[settings]
714base_branch = "main"
715unmatched_files = "ignore"
716
717[[components]]
718name = "app"
719globs = ["src/**"]
720"#;
721 let config: Config = toml::from_str(config_str).unwrap();
722 let matcher = ComponentMatcher::from_config(&config).unwrap();
723 let repo = crate::git::open_repo(dir.path()).unwrap();
724
725 let (results, effects) =
726 plan_atomize_range(&repo, &config, &matcher, &[], &HashSet::new(), false).unwrap();
727
728 assert!(results.is_empty());
729 assert!(effects.is_empty());
730 }
731}