Skip to main content

sley_ref_filter/
tracking.rs

1//! Upstream / push destination resolution and ahead-behind tracking.
2
3use super::ForEachRefTrack;
4use sley_config::GitConfig;
5use sley_core::{ObjectId, ObjectFormat, Result};
6use sley_odb::{FileObjectDatabase, ObjectReader};
7use sley_refs::{FileRefStore, Ref, RefTarget, validate_ref_name};
8use std::path::Path;
9
10#[derive(Clone)]
11pub struct ForEachRefUpstream {
12    pub refname: String,
13    pub remote: String,
14    pub merge: String,
15}
16
17#[derive(Clone)]
18pub struct ForEachRefPush {
19    pub refname: Option<String>,
20    pub remote: String,
21    pub remote_ref: Option<String>,
22}
23
24pub struct ForEachRefPushRemote {
25    name: String,
26    expose_name: bool,
27}
28
29pub fn for_each_ref_upstream(
30    config: &GitConfig,
31    refname: &str,
32) -> Option<ForEachRefUpstream> {
33    let branch = refname.strip_prefix("refs/heads/")?;
34    let remote = config.get("branch", Some(branch), "remote")?;
35    let merge = config.get("branch", Some(branch), "merge")?;
36    if remote == "." {
37        // git's `set_merge` for the local remote `.`: when fetch refspec mapping
38        // fails, `repo_dwim_ref` expands a short merge name (e.g. `main`) to the
39        // unique matching ref (`refs/heads/main`). Local-branch upstreams almost
40        // always live under `refs/heads/`; fully-qualified `refs/*` values are
41        // kept as-is.
42        let refname = expand_local_upstream_merge(merge);
43        return Some(ForEachRefUpstream {
44            refname,
45            remote: remote.to_string(),
46            merge: merge.to_string(),
47        });
48    }
49    let fetch = config.get("remote", Some(remote), "fetch")?;
50    Some(ForEachRefUpstream {
51        refname: map_remote_fetch_refspec(fetch, merge)?,
52        remote: remote.to_string(),
53        merge: merge.to_string(),
54    })
55}
56
57/// Expand a loosely defined local `branch.<name>.merge` value the way git's
58/// `set_merge` + `repo_dwim_ref` does for remote `.`.
59pub fn expand_local_upstream_merge(merge: &str) -> String {
60    if merge.starts_with("refs/") {
61        merge.to_string()
62    } else {
63        format!("refs/heads/{merge}")
64    }
65}
66
67pub fn for_each_ref_push(config: &GitConfig, refname: &str) -> Option<ForEachRefPush> {
68    let branch = refname.strip_prefix("refs/heads/")?;
69    let push_remote = for_each_ref_push_remote(config, branch)?;
70    let remote_name = push_remote.name.clone();
71    // The display name is exposed by `%(push:remotename)` even when the push
72    // destination itself does not resolve, so compute it up front and keep it
73    // on every return path (git's branch_get_push reports the remote regardless).
74    let display_remote = remote_display_name(push_remote);
75    if remote_name == "." {
76        return Some(ForEachRefPush {
77            refname: None,
78            remote: display_remote,
79            remote_ref: None,
80        });
81    }
82    // An explicit push refspec (remote.<name>.push) takes precedence over
83    // push.default — mirrors `remote->push.nr` in git's branch_get_push_1.
84    if let Some(push) = config.get("remote", Some(remote_name.as_str()), "push") {
85        if let Some(remote_ref) = map_remote_push_refspec(push, refname) {
86            let tracking = map_remote_tracking_ref(config, &remote_name, &remote_ref);
87            return Some(ForEachRefPush {
88                refname: tracking,
89                remote: display_remote,
90                remote_ref: Some(remote_ref),
91            });
92        }
93        return Some(ForEachRefPush {
94            refname: None,
95            remote: display_remote,
96            remote_ref: None,
97        });
98    }
99    // Otherwise resolve the destination through push.default, exactly as
100    // git's branch_get_push_1 switch does.
101    let push_default = config.get("push", None, "default").unwrap_or("simple");
102    let tracking = match push_default {
103        "nothing" => None,
104        // matching/current push the branch's own ref through the push remote's
105        // fetch refspec (tracking_for_push_dest on branch->refname).
106        "matching" | "current" => map_remote_tracking_ref(config, &remote_name, refname),
107        // upstream uses the branch's configured upstream destination.
108        "upstream" => for_each_ref_upstream(config, refname).map(|up| up.refname),
109        // simple/unspecified (the default): the push destination must equal the
110        // upstream destination, otherwise there is no single 'simple' target and
111        // %(push) is empty (the remote name is still reported).
112        _ => {
113            let up = for_each_ref_upstream(config, refname).map(|up| up.refname);
114            let cur = map_remote_tracking_ref(config, &remote_name, refname);
115            match (up, cur) {
116                (Some(up), Some(cur)) if up == cur => Some(cur),
117                _ => None,
118            }
119        }
120    };
121    Some(ForEachRefPush {
122        refname: tracking,
123        remote: display_remote,
124        remote_ref: None,
125    })
126}
127
128pub fn for_each_ref_push_remote(
129    config: &GitConfig,
130    branch: &str,
131) -> Option<ForEachRefPushRemote> {
132    if let Some(remote) = config.get("branch", Some(branch), "pushRemote") {
133        return Some(ForEachRefPushRemote {
134            name: remote.to_string(),
135            expose_name: true,
136        });
137    }
138    if let Some(remote) = config.get("remote", None, "pushDefault") {
139        return Some(ForEachRefPushRemote {
140            name: remote.to_string(),
141            expose_name: true,
142        });
143    }
144    if let Some(remote) = config.get("branch", Some(branch), "remote") {
145        return Some(ForEachRefPushRemote {
146            name: remote.to_string(),
147            expose_name: true,
148        });
149    }
150    if sley_config::remotes::remote_exists(config, "origin") {
151        return Some(ForEachRefPushRemote {
152            name: "origin".to_string(),
153            expose_name: false,
154        });
155    }
156    let remotes = sley_config::remotes::remote_names(config);
157    match remotes.as_slice() {
158        [remote] => Some(ForEachRefPushRemote {
159            name: remote.clone(),
160            expose_name: false,
161        }),
162        _ => None,
163    }
164}
165
166pub fn remote_display_name(remote: ForEachRefPushRemote) -> String {
167    if remote.expose_name {
168        remote.name
169    } else {
170        String::new()
171    }
172}
173
174pub fn map_remote_tracking_ref(
175    config: &GitConfig,
176    remote: &str,
177    remote_ref: &str,
178) -> Option<String> {
179    let fetch = config.get("remote", Some(remote), "fetch")?;
180    map_remote_fetch_refspec(fetch, remote_ref)
181}
182
183pub fn map_remote_push_refspec(refspec: &str, refname: &str) -> Option<String> {
184    let refspec = parse_refspec(refspec).ok()?;
185    if refspec.negative || refspec.src.is_none() || refspec.dst.is_none() {
186        return None;
187    }
188    refspec_map_source(&refspec, refname).ok()?
189}
190
191pub fn map_remote_fetch_refspec(refspec: &str, merge: &str) -> Option<String> {
192    let refspec = parse_refspec(refspec).ok()?;
193    if refspec.negative || refspec.dst.is_none() {
194        return None;
195    }
196    refspec_map_source(&refspec, merge).ok()?
197}
198
199use sley_protocol::parse_refspec;
200use sley_protocol::refspec_map_source;
201
202pub fn for_each_ref_upstream_track(
203    store: &FileRefStore,
204    git_dir: &Path,
205    db: &FileObjectDatabase,
206    format: ObjectFormat,
207    oid: &ObjectId,
208    upstream: &str,
209) -> Result<Option<ForEachRefTrack>> {
210    // git: a configured-but-unresolvable upstream reports `[gone]`, distinct
211    // from "no upstream configured" (which the caller already filtered out).
212    let gone_track = ForEachRefTrack {
213        ahead: 0,
214        behind: 0,
215        gone: true,
216    };
217    let Some(upstream_target) = store.read_ref(upstream)? else {
218        return Ok(Some(gone_track));
219    };
220    let upstream_ref = Ref {
221        name: upstream.to_string(),
222        target: upstream_target,
223    };
224    let Some((upstream_oid, _)) = resolve_for_each_ref_target(store, &upstream_ref)? else {
225        return Ok(Some(gone_track));
226    };
227    for_each_ref_ahead_behind(git_dir, db, format, oid, &upstream_oid)
228}
229
230pub fn for_each_ref_ahead_behind_with_diagnostic(
231    git_dir: &Path,
232    db: &FileObjectDatabase,
233    format: ObjectFormat,
234    oid: &ObjectId,
235    target: &ObjectId,
236) -> Result<Option<ForEachRefTrack>> {
237    let Ok(local_commit) = sley_rev::peel_to_commit(db, format, oid) else {
238        if let Ok(object) = db.read_object(oid) {
239            eprintln!(
240                "error: object {} is a {}, not a commit",
241                oid,
242                object.object_type.as_str()
243            );
244        }
245        return Ok(None);
246    };
247    let Ok(target_commit) = sley_rev::peel_to_commit(db, format, target) else {
248        return Ok(None);
249    };
250    let (ahead, behind) =
251        sley_rev::ahead_behind_counts(git_dir, format, db, &local_commit, &target_commit)?;
252    Ok(Some(ForEachRefTrack {
253        ahead,
254        behind,
255        gone: false,
256    }))
257}
258
259pub fn for_each_ref_ahead_behind(
260    git_dir: &Path,
261    db: &FileObjectDatabase,
262    format: ObjectFormat,
263    oid: &ObjectId,
264    target: &ObjectId,
265) -> Result<Option<ForEachRefTrack>> {
266    let Ok(local_commit) = sley_rev::peel_to_commit(db, format, oid) else {
267        return Ok(None);
268    };
269    let Ok(target_commit) = sley_rev::peel_to_commit(db, format, target) else {
270        return Ok(None);
271    };
272    let (ahead, behind) =
273        sley_rev::ahead_behind_counts(git_dir, format, db, &local_commit, &target_commit)?;
274    Ok(Some(ForEachRefTrack {
275        ahead,
276        behind,
277        gone: false,
278    }))
279}
280
281pub fn resolve_for_each_ref_target(
282    store: &FileRefStore,
283    reference: &Ref,
284) -> Result<Option<(ObjectId, Option<String>)>> {
285    let mut target = reference.target.clone();
286    let mut symref = None;
287    for _ in 0..5 {
288        match target {
289            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
290            RefTarget::Symbolic(name) => {
291                symref.get_or_insert_with(|| name.clone());
292                if validate_ref_name(&name).is_err() {
293                    return Ok(None);
294                }
295                let Some(next) = store.read_ref(&name)? else {
296                    return Ok(None);
297                };
298                target = next;
299            }
300        }
301    }
302    Ok(None)
303}