Skip to main content

git_atomic/git/
atomize.rs

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/// Result of atomizing a single component.
11#[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
20/// Plan atomization of a single source commit into per-component branches.
21///
22/// Returns the results and a list of effects to execute. Tree/commit object
23/// writes happen inline (they're immutable and harmless without refs), but
24/// the ref transaction is returned as an effect for the caller to execute.
25pub 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    // Handle unmatched files
48    if !unmatched.is_empty() {
49        handle_unmatched(config, &unmatched)?;
50    }
51
52    // Resolve base commit for branch management
53    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    // Build all commits (all-or-nothing)
57    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
126/// Plan atomization of a range of commits with partial-squash semantics.
127///
128/// Net-zero files (unchanged between range endpoints) are filtered out.
129/// Commits with no remaining effective changes are skipped. Each component
130/// branch gets incremental (cumulative) trees — coherent and checkable-out
131/// at every point.
132pub 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    // Track per-component state across commits:
148    // - cumulative files seen so far (for incremental trees)
149    // - current parent commit id (for chaining)
150    // - whether the branch existed before this range
151    struct ComponentState {
152        cumulative_files: Vec<PathBuf>,
153        parent_id: ObjectId,
154        ref_name: String,
155        branch_display: String,
156        created: bool,
157        /// The previous ref value before any range processing (for the RefTransaction).
158        original_previous: Option<ObjectId>,
159        /// The latest commit id written for this component.
160        latest_commit_id: ObjectId,
161        /// Total effective files across all commits (for the final AtomicResult).
162        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        // Filter to only effective files
171        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        // Handle unmatched effective files
192        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                    // Add new files to cumulative set (avoid duplicates)
212                    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                    // Build incremental tree from cumulative files
223                    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                    // First time seeing this component — initialize state
242                    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    // Build ref edits and results from final component states
297    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
329/// Handle unmatched files according to the configured policy.
330fn 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
350/// Extract the first line of the commit message as a summary.
351fn 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        // Initial empty commit on main
380        git(dir, &["commit", "--allow-empty", "-m", "initial"]);
381
382        // Add multi-component files
383        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        // c1: add foo.ts and bar.ts
530        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        // c2: modify foo.ts
537        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        // c3: delete foo.ts
542        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        // foo.ts is net-zero (added then deleted), bar.ts is effective
554        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        // Only one component result (frontend)
573        assert_eq!(results.len(), 1);
574        assert_eq!(results[0].component, "frontend");
575        // Files should only include bar.ts
576        assert_eq!(results[0].files, vec![PathBuf::from("src/ui/bar.ts")]);
577
578        // Execute and verify the branch
579        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        // c1: add bar.ts
607        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        // c2: add baz.ts
613        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        // c3: modify baz.ts
618        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        // bar.ts, baz.ts, plus directory entries (src, src/ui)
631        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        // Final tree should have both bar.ts and baz.ts (incremental)
656        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        // Walk back: the final commit's parent should also have bar.ts
670        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        // And the grandparent (first component commit) should have only bar.ts
687        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}