Skip to main content

sley_submodule/
move_head.rs

1//! Submodule move-head / verify-clean primitives — a Rust port of the
2//! verification half of git's `submodule.c::submodule_move_head` plus the
3//! `unpack-trees.c` wrappers `check_submodule_move_head` and
4//! `verify_clean_submodule`.
5//!
6//! These are exactly the hooks a tree-switch engine (checkout / reset /
7//! read-tree, i.e. `unpack-trees`) needs to answer "would moving this
8//! submodule's HEAD from `old` to `new` lose uncommitted work?" *before*
9//! touching the worktree. They are written as pure decision logic over a
10//! caller-supplied [`MoveHeadContext`]: the crate owns the algorithm (the
11//! active/populated gating and the dirty-index → reject rule); the caller owns
12//! the I/O (resolving the submodule gitdir, reading its index, checking
13//! dirtiness). That split keeps this crate dependency-light and lets both the
14//! `submodule` CLI command and the tree-switch commands drive the SAME logic.
15//!
16//! ## Mapping to git
17//!
18//! | git symbol | here |
19//! |---|---|
20//! | `submodule_move_head(..., DRY_RUN)` | [`check_move_head`] |
21//! | `check_submodule_move_head` (unpack-trees.c) | [`check_move_head`] |
22//! | `verify_clean_submodule` (unpack-trees.c) | [`verify_clean_submodule`] |
23//! | `SUBMODULE_MOVE_HEAD_FORCE` | [`MoveHeadFlags::force`] |
24//! | `is_submodule_active` | [`MoveHeadContext::active`] |
25//! | `is_submodule_populated_gently` | [`MoveHeadContext::populated`] |
26//! | `submodule_has_dirty_index` | [`MoveHeadContext::has_dirty_index`] |
27
28/// Flags controlling a move-head check, porting `SUBMODULE_MOVE_HEAD_*`.
29///
30/// Note: only the verification (dry-run) decision is modeled here, which is all
31/// `check_submodule_move_head` ever needs (it always sets `DRY_RUN`). The
32/// non-dry-run mutation path of `submodule_move_head` (absorb gitdir, run
33/// `read-tree -u`, update HEAD) is a CLI/engine concern and lives with the
34/// caller — see TODO(submodule) below.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct MoveHeadFlags {
37    /// `SUBMODULE_MOVE_HEAD_FORCE` — set on a hard `reset`. When forced, a dirty
38    /// index does NOT block the move (git skips the dirty-index check).
39    pub force: bool,
40}
41
42/// Everything the move-head decision needs about one submodule at one path.
43/// The caller resolves these via whatever I/O it already has (the CLI reads the
44/// submodule's `.git` + index; the unpack-trees engine reads the in-memory
45/// index entry + on-disk submodule).
46pub struct MoveHeadContext {
47    /// `is_submodule_active(the_repository, path)` — is the submodule active in
48    /// this superproject (via `submodule.<name>.active` / `active`-pathspec /
49    /// a configured url)? An inactive submodule is never touched, so a move can
50    /// never lose its data.
51    pub active: bool,
52    /// `is_submodule_populated_gently(path, ...)` — does `<path>/.git` resolve
53    /// to a real git repository? An unpopulated submodule has no checked-out
54    /// work to lose when `old_head` is set.
55    pub populated: bool,
56    /// `submodule_has_dirty_index(sub)` — does the submodule have staged but
57    /// uncommitted changes relative to its HEAD (`git diff-index --quiet
58    /// --cached HEAD` is non-zero)? A dirty index blocks a non-forced move.
59    pub has_dirty_index: bool,
60}
61
62/// The verdict of a move-head check, porting the return contract of
63/// `submodule_move_head` in DRY_RUN mode as consumed by
64/// `check_submodule_move_head`.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum MoveHeadVerdict {
67    /// The move is safe: no submodule work would be lost. (git returns 0.)
68    Ok,
69    /// The move would lose submodule work and must be rejected — the
70    /// unpack-trees caller records this as `ERROR_WOULD_LOSE_SUBMODULE` for
71    /// `ce->name`. (git returns non-zero from `submodule_move_head`.)
72    WouldLose,
73}
74
75/// Port of `submodule_move_head` (git `submodule.c`) restricted to the dry-run
76/// verification path that `check_submodule_move_head` uses — the actual hook a
77/// tree-switch engine calls.
78///
79/// `old_head` is `None` when the submodule is *coming into existence* in the
80/// new tree (git passes `NULL` for old); `new_head` is `None` when it is being
81/// *removed*. The decision:
82///
83/// 1. Inactive submodule → [`MoveHeadVerdict::Ok`] (git's early `return 0`).
84/// 2. `old_head` set but the submodule is not populated → `Ok` (nothing to
85///    lose; git's `return 0`).
86/// 3. `old_head` set, not forced, and the index is dirty → [`MoveHeadVerdict::WouldLose`]
87///    (git's `error("submodule '%s' has dirty index")`).
88/// 4. Otherwise the dry-run `read-tree -n -m` would succeed → `Ok`.
89///
90/// Step 4's `read-tree -n` (a no-op-checking merge) can itself reject when the
91/// new tree would clobber tracked-but-modified worktree files. That nested
92/// check is left to the caller's worktree-status pass — see TODO(submodule).
93pub fn check_move_head(
94    ctx: &MoveHeadContext,
95    old_head: Option<&str>,
96    _new_head: Option<&str>,
97    flags: MoveHeadFlags,
98) -> MoveHeadVerdict {
99    // 1. `if (!is_submodule_active(the_repository, path)) return 0;`
100    if !ctx.active {
101        return MoveHeadVerdict::Ok;
102    }
103
104    // 2. `if (old_head && !is_submodule_populated_gently(path, ...)) return 0;`
105    if old_head.is_some() && !ctx.populated {
106        return MoveHeadVerdict::Ok;
107    }
108
109    // 3. `if (old_head && !FORCE) { if (submodule_has_dirty_index(sub)) error(...); }`
110    if old_head.is_some() && !flags.force && ctx.has_dirty_index {
111        return MoveHeadVerdict::WouldLose;
112    }
113
114    // 4. The dry-run read-tree succeeds (nested worktree-clobber check is the
115    //    caller's; see module docs). git returns 0.
116    MoveHeadVerdict::Ok
117}
118
119/// Port of `check_submodule_move_head` (git `unpack-trees.c`). Thin wrapper that
120/// always runs in dry-run mode and translates `o->reset` into the FORCE flag.
121///
122/// `is_submodule` mirrors `submodule_from_ce(ce)`: when the cache entry is not a
123/// submodule (no `.gitmodules` binding), the check is a no-op `Ok`. The caller
124/// resolves that from the typed [`crate::config::SubmoduleConfigSet`].
125pub fn check_submodule_move_head(
126    is_submodule: bool,
127    ctx: &MoveHeadContext,
128    old_id: Option<&str>,
129    new_id: Option<&str>,
130    reset: bool,
131) -> MoveHeadVerdict {
132    if !is_submodule {
133        return MoveHeadVerdict::Ok;
134    }
135    let flags = MoveHeadFlags { force: reset };
136    check_move_head(ctx, old_id, new_id, flags)
137}
138
139/// Port of `verify_clean_submodule` (git `unpack-trees.c`). Checks that checking
140/// out `ce`'s oid in the submodule subdir won't overwrite working files, given
141/// the submodule's current head (`old_sha1`, `None` when HEAD is unresolved).
142///
143/// In git this is literally `check_submodule_move_head(ce, old_sha1,
144/// oid_to_hex(&ce->oid), o)` with the same `submodule_from_ce` short-circuit.
145pub fn verify_clean_submodule(
146    is_submodule: bool,
147    ctx: &MoveHeadContext,
148    old_sha1: Option<&str>,
149    new_oid_hex: &str,
150    reset: bool,
151) -> MoveHeadVerdict {
152    if !is_submodule {
153        return MoveHeadVerdict::Ok;
154    }
155    check_submodule_move_head(true, ctx, old_sha1, Some(new_oid_hex), reset)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn ctx(active: bool, populated: bool, dirty: bool) -> MoveHeadContext {
163        MoveHeadContext {
164            active,
165            populated,
166            has_dirty_index: dirty,
167        }
168    }
169
170    #[test]
171    fn inactive_submodule_is_always_ok() {
172        let c = ctx(false, true, true);
173        assert_eq!(
174            check_move_head(&c, Some("old"), Some("new"), MoveHeadFlags { force: false }),
175            MoveHeadVerdict::Ok
176        );
177    }
178
179    #[test]
180    fn unpopulated_with_old_head_is_ok() {
181        let c = ctx(true, false, true);
182        assert_eq!(
183            check_move_head(&c, Some("old"), Some("new"), MoveHeadFlags::default()),
184            MoveHeadVerdict::Ok
185        );
186    }
187
188    #[test]
189    fn dirty_index_blocks_non_forced_move() {
190        let c = ctx(true, true, true);
191        assert_eq!(
192            check_move_head(&c, Some("old"), Some("new"), MoveHeadFlags { force: false }),
193            MoveHeadVerdict::WouldLose
194        );
195    }
196
197    #[test]
198    fn force_bypasses_dirty_index() {
199        let c = ctx(true, true, true);
200        assert_eq!(
201            check_move_head(&c, Some("old"), Some("new"), MoveHeadFlags { force: true }),
202            MoveHeadVerdict::Ok
203        );
204    }
205
206    #[test]
207    fn coming_into_existence_skips_dirty_check() {
208        // old_head == None: submodule is new in the target tree, no dirty
209        // gating applies.
210        let c = ctx(true, true, true);
211        assert_eq!(
212            check_move_head(&c, None, Some("new"), MoveHeadFlags::default()),
213            MoveHeadVerdict::Ok
214        );
215    }
216
217    #[test]
218    fn unpack_trees_wrappers_short_circuit_non_submodule() {
219        let c = ctx(true, true, true);
220        assert_eq!(
221            check_submodule_move_head(false, &c, Some("old"), Some("new"), false),
222            MoveHeadVerdict::Ok
223        );
224        assert_eq!(
225            verify_clean_submodule(false, &c, Some("old"), "newoid", false),
226            MoveHeadVerdict::Ok
227        );
228    }
229
230    #[test]
231    fn verify_clean_submodule_rejects_dirty() {
232        let c = ctx(true, true, true);
233        assert_eq!(
234            verify_clean_submodule(true, &c, Some("old"), "newoid", false),
235            MoveHeadVerdict::WouldLose
236        );
237    }
238
239    #[test]
240    fn reset_sets_force() {
241        let c = ctx(true, true, true);
242        // reset=true → force, so dirty index does NOT block.
243        assert_eq!(
244            check_submodule_move_head(true, &c, Some("old"), Some("new"), true),
245            MoveHeadVerdict::Ok
246        );
247    }
248}