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}