Skip to main content

choir_node/
provision.rs

1//! Instant workspace provisioning (`POST /api/workspace`) — the D21
2//! "productized provisioning" wedge feature.
3//!
4//! A workspace is a copy-on-write clone of a per-repo template checkout
5//! (APFS `clonefile` on macOS, `--reflink=auto` on Linux), so creation
6//! cost is O(directory entries), not O(bytes). Each created workspace is
7//! registered in the platform view with a node-signed operation, so
8//! `/api/view` is the durable workspace inventory. Legacy requests use
9//! `SetWorkspaceHead`; adapter-grade requests atomically create a stable
10//! change binding at an exact revision.
11//!
12//! The workspace's `origin` is rewritten to the daemon's own HTTP URL:
13//! pushes from a workspace go through the sequenced smart-HTTP path,
14//! never straight at the bare repo on disk.
15//!
16//! # Why the template has maintenance switched off
17//!
18//! The copy source is a real git repository, and git runs housekeeping
19//! on its own initiative: a `fetch` or `checkout` can trigger auto-gc or
20//! `git maintenance run --auto`, which creates and deletes files under
21//! `.git/objects` — `maintenance.lock` and `gc.pid` among them. `cp`
22//! walks a directory by stating entries and then opening them, so a file
23//! that vanishes between those two steps is a hard error, and the caller
24//! sees a `500` for a workspace that was never at fault. It was observed
25//! once as a test flake (`cp: …/.git/objects/maintenance.lock: No such
26//! file or directory`) and is a real user-facing race, not a test
27//! artifact.
28//!
29//! So every git command this module runs against the template carries
30//! `gc.auto=0` and `maintenance.auto=false`, which turn both mechanisms
31//! off (see the private `TEMPLATE_CONFIG`). The template is
32//! a disposable internal artifact — re-fetched constantly, never served,
33//! recreated by deleting it — so housekeeping buys nothing there and
34//! costs a race. The alternative, tolerating `ENOENT` from `cp`, means
35//! parsing a localized subprocess error to guess which vanished files
36//! were harmless, and would leave the race in place.
37
38use std::path::{Path, PathBuf};
39
40use choir_oplog::ContentHash;
41
42use crate::platform::{AuthorizedChangeCreate, Platform};
43use crate::reject::{Code, Rejection};
44
45/// Command-line config making git's own housekeeping stay out of a
46/// template while it is being copied.
47///
48/// Passed per command rather than only persisted at clone time, so it
49/// also covers templates created before this existed: nothing else on
50/// the node touches a template, so these four arguments are the whole
51/// exposure. They are *also* written into new templates at clone time,
52/// so anything that reaches one later inherits the same rule.
53const TEMPLATE_CONFIG: [&str; 4] = ["-c", "gc.auto=0", "-c", "maintenance.auto=false"];
54
55/// Path segment allowed in repo/workspace names: no traversal, no
56/// hidden files, no separators.
57pub(crate) fn safe_segment(s: &str) -> bool {
58    !s.is_empty()
59        && !s.starts_with('.')
60        && s.chars()
61            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
62}
63
64/// Runs git, returning stdout or a failure description.
65fn git(args: &[&str], dir: Option<&Path>) -> Result<String, String> {
66    let mut cmd = std::process::Command::new("git");
67    cmd.args(args).env("GIT_TERMINAL_PROMPT", "0");
68    if let Some(d) = dir {
69        cmd.current_dir(d);
70    }
71    let out = cmd.output().map_err(|e| format!("spawn git: {e}"))?;
72    if out.status.success() {
73        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
74    } else {
75        Err(format!(
76            "git {args:?}: {}",
77            String::from_utf8_lossy(&out.stderr)
78        ))
79    }
80}
81
82/// Copy-on-write directory copy: `clonefile` on macOS, reflink (with
83/// silent fallback to plain copy on non-CoW filesystems) on Linux.
84fn cow_copy(src: &Path, dst: &Path) -> Result<(), String> {
85    let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
86        ("cp", vec!["-Rc"])
87    } else {
88        ("cp", vec!["-R", "--reflink=auto"])
89    };
90    let out = std::process::Command::new(cmd)
91        .args(&args)
92        .arg(src)
93        .arg(dst)
94        .output()
95        .map_err(|e| format!("spawn cp: {e}"))?;
96    if out.status.success() {
97        Ok(())
98    } else {
99        Err(format!(
100            "cow copy: {}",
101            String::from_utf8_lossy(&out.stderr)
102        ))
103    }
104}
105
106/// One mutex per repo: template refresh and CoW copy must be serialized
107/// per repo, or a concurrent request can copy a mid-checkout template
108/// (and two same-name requests can both pass the exists check).
109/// Different repos provision in parallel.
110fn repo_lock(repo: &str) -> std::sync::Arc<std::sync::Mutex<()>> {
111    static LOCKS: std::sync::OnceLock<
112        std::sync::Mutex<std::collections::BTreeMap<String, std::sync::Arc<std::sync::Mutex<()>>>>,
113    > = std::sync::OnceLock::new();
114    LOCKS
115        .get_or_init(Default::default)
116        .lock()
117        .expect("lock table")
118        .entry(repo.to_string())
119        .or_default()
120        .clone()
121}
122
123/// Handles `POST /api/workspace`; body `{"repo": "owner/repo",
124/// "name": "<workspace>"}`. Returns `(status, json_body)` like the rest
125/// of the platform API. `base_url` is the daemon's own address, used as
126/// the workspace's `origin` so pushes stay on the sequenced path.
127pub fn create_workspace(
128    root: &Path,
129    platform: &Platform,
130    base_url: &str,
131    authenticated_user: &str,
132    body: &[u8],
133) -> (u16, String) {
134    let req: serde_json::Value = match serde_json::from_slice(body) {
135        Ok(v) => v,
136        Err(e) => return (400, format!(r#"{{"error":"bad json: {e}"}}"#)),
137    };
138    let field = |k: &str| req.get(k).and_then(|v| v.as_str()).unwrap_or("");
139    let (repo, name) = (field("repo"), field("name"));
140    let mut segs = repo.split('/');
141    let (Some(owner), Some(reponame), None) = (segs.next(), segs.next(), segs.next()) else {
142        return (400, r#"{"error":"repo must be owner/repo"}"#.to_string());
143    };
144    if !safe_segment(owner) || !safe_segment(reponame) || !safe_segment(name) {
145        return (400, r#"{"error":"bad repo or workspace name"}"#.to_string());
146    }
147
148    let advanced_fields = ["base", "owner", "change", "idempotency_key"];
149    let advanced = advanced_fields.iter().any(|key| req.get(*key).is_some());
150    if advanced
151        && advanced_fields.iter().any(|key| {
152            req.get(*key)
153                .and_then(|v| v.as_str())
154                .is_none_or(str::is_empty)
155        })
156    {
157        return problem(
158            400,
159            Code::MalformedRequest,
160            "advanced workspace creation requires non-empty base, owner, change and idempotency_key",
161            "send all four fields, or omit all four to use the legacy HEAD-based request",
162        );
163    }
164    let attribution = crate::quota::channel_for(authenticated_user);
165
166    let bare = root.join(format!("{repo}.git"));
167    if !bare.join("HEAD").exists() {
168        return (404, r#"{"error":"no such repo"}"#.to_string());
169    }
170
171    let lock = repo_lock(repo);
172    let _guard = lock.lock().expect("repo lock");
173    let requested_base = field("base");
174    let head = if advanced {
175        if ContentHash::from_git_oid(requested_base).is_none() {
176            return problem(
177                400,
178                Code::MalformedRequest,
179                "base must be a full 40- or 64-character Git object id",
180                "resolve the desired commit to its full object id and retry",
181            );
182        }
183        match git(&["cat-file", "-t", requested_base], Some(&bare)) {
184            Ok(kind) if kind.trim() == "commit" => match git(
185                &["rev-parse", "--verify", &format!("{requested_base}^{{commit}}")],
186                Some(&bare),
187            ) {
188                Ok(commit) => commit.trim().to_string(),
189                Err(_) => {
190                    return problem(
191                        400,
192                        Code::MalformedRequest,
193                        "base does not resolve to a commit in this repository",
194                        "fetch the exact commit into the repository, then retry with its full object id",
195                    )
196                }
197            },
198            _ => {
199                return problem(
200                    400,
201                    Code::MalformedRequest,
202                    "base does not name a commit in this repository",
203                    "fetch the exact commit into the repository, then retry with its full object id",
204                )
205            }
206        }
207    } else {
208        match git(&["rev-parse", "--verify", "HEAD^{commit}"], Some(&bare)) {
209            Ok(h) => h.trim().to_string(),
210            Err(_) => return (400, r#"{"error":"repository has no commits"}"#.to_string()),
211        }
212    };
213
214    let ws_dir = root.join(".choir").join("workspaces").join(repo).join(name);
215    let workspace = format!("{repo}/{name}");
216    let owner_sig = if advanced {
217        let base_revision = ContentHash::from_git_oid(&head).expect("verified git oid");
218        match platform.decode_create_change_request(
219            &req,
220            field("change"),
221            field("owner"),
222            &workspace,
223            &base_revision,
224            field("idempotency_key"),
225        ) {
226            Ok(signed) => Some(signed),
227            Err(reason) => {
228                let rejection = Rejection::decode(&reason);
229                let status = if rejection.code == Code::WorkspaceState.as_str() {
230                    409
231                } else {
232                    400
233                };
234                return (status, rejection.body());
235            }
236        }
237    } else {
238        None
239    };
240    if ws_dir.exists() {
241        if advanced {
242            let verified = owner_sig.expect("advanced request was verified");
243            return reuse_or_conflict(
244                platform,
245                &workspace,
246                &ws_dir,
247                AdvancedBinding {
248                    base_hex: &head,
249                    owner: field("owner"),
250                    change_id: field("change"),
251                    idempotency_key: field("idempotency_key"),
252                    owner_sig: verified.0,
253                    cone: verified.1,
254                    attribution: &attribution,
255                },
256            );
257        }
258        return (409, r#"{"error":"workspace already exists"}"#.to_string());
259    }
260    if advanced
261        && (platform.change_state(field("change")).is_some()
262            || platform
263                .change_for_idempotency(field("owner"), field("idempotency_key"))
264                .is_some()
265            || platform.workspace_head(&workspace).is_some())
266    {
267        return lifecycle_conflict(
268            "the requested change, idempotency key or workspace already has a different durable state",
269        );
270    }
271
272    match provision(root, &bare, repo, &head, &ws_dir, base_url) {
273        Ok(copy_ms) => {
274            let registration = if advanced {
275                let verified = owner_sig.expect("advanced request was verified");
276                platform
277                    .create_change(
278                        AuthorizedChangeCreate {
279                            id: field("change"),
280                            owner: field("owner"),
281                            workspace: &workspace,
282                            base_hex: &head,
283                            idempotency_key: field("idempotency_key"),
284                            owner_sig: verified.0,
285                            cone: verified.1,
286                        },
287                        &attribution,
288                    )
289                    .map(Some)
290            } else {
291                platform
292                    .set_workspace_head(&workspace, &head, &attribution)
293                    .map(|()| None)
294            };
295            let accepted = match registration {
296                Ok(accepted) => accepted,
297                Err(reason) => {
298                    // Roll back the directory so a rejected registration
299                    // leaves no half-created workspace.
300                    std::fs::remove_dir_all(&ws_dir).ok();
301                    return (409, Rejection::decode(&reason).body());
302                }
303            };
304            let mut response = serde_json::json!({
305                "workspace": workspace,
306                "path": ws_dir.display().to_string(),
307                "head": head,
308                "copy_ms": copy_ms,
309                "created": true,
310            });
311            if advanced {
312                response["change_id"] = serde_json::json!(field("change"));
313                response["owner"] = serde_json::json!(field("owner"));
314                response["idempotency_key"] = serde_json::json!(field("idempotency_key"));
315                response["base_revision"] = serde_json::json!(ContentHash::from_git_oid(&head)
316                    .expect("verified git oid")
317                    .to_hex());
318                if let Some(accepted) = accepted {
319                    response["operation"] = serde_json::json!({
320                        "seq": accepted.seq,
321                        "hash": accepted.hash.to_hex(),
322                    });
323                }
324            }
325            (200, response.to_string())
326        }
327        Err(e) => (500, serde_json::json!({ "error": e }).to_string()),
328    }
329}
330
331/// Handles `POST /api/workspace/archive`. The live checkout is moved to a
332/// recoverable same-filesystem archive before the durable view operation.
333/// A sequencing refusal restores the live path.
334pub fn archive_workspace(
335    root: &Path,
336    platform: &Platform,
337    authenticated_user: &str,
338    body: &[u8],
339) -> (u16, String) {
340    let req: serde_json::Value = match serde_json::from_slice(body) {
341        Ok(v) => v,
342        Err(e) => return problem(
343            400,
344            Code::MalformedRequest,
345            format!("request body is not valid JSON: {e}"),
346            "send repo, name, change, idempotency_key and an owner-signed archive authorization",
347        ),
348    };
349    let field = |key: &str| req.get(key).and_then(|v| v.as_str()).unwrap_or("");
350    let (repo, name) = (field("repo"), field("name"));
351    let mut segs = repo.split('/');
352    let (Some(repo_owner), Some(reponame), None) = (segs.next(), segs.next(), segs.next()) else {
353        return problem(
354            400,
355            Code::MalformedRequest,
356            "repo must be owner/repo",
357            "send a two-segment repository name",
358        );
359    };
360    if !safe_segment(repo_owner) || !safe_segment(reponame) || !safe_segment(name) {
361        return problem(
362            400,
363            Code::MalformedRequest,
364            "bad repo or workspace name",
365            "use non-hidden alphanumeric, dot, underscore or hyphen path segments",
366        );
367    }
368    if ["change", "idempotency_key", "channel"]
369        .iter()
370        .any(|key| field(key).is_empty())
371    {
372        return problem(
373            400,
374            Code::MalformedRequest,
375            "archive requires non-empty change, idempotency_key and signed channel",
376            "retry with the exact binding returned by workspace creation and an owner-signed ArchiveAuthorization payload",
377        );
378    }
379
380    let lock = repo_lock(repo);
381    let _guard = lock.lock().expect("repo lock");
382    let attribution = crate::quota::channel_for(authenticated_user);
383    let workspace = format!("{repo}/{name}");
384    let Some(change) = platform.change_state(field("change")) else {
385        return lifecycle_conflict("no durable change has the requested identity");
386    };
387    if change.owner != field("channel")
388        || change.workspace_id != workspace
389        || change.idempotency_key != field("idempotency_key")
390    {
391        return lifecycle_conflict(
392            "archive binding does not match the durable change channel, workspace and idempotency key",
393        );
394    }
395
396    let live = root.join(".choir").join("workspaces").join(repo).join(name);
397    let archive_root = root
398        .join(".choir")
399        .join("archive")
400        .join("workspaces")
401        .join(repo)
402        .join(name);
403    let archived = archive_root.join(archive_generation(field("change")));
404    if change.active_workspace.is_none() {
405        let existing_archive = if archived.exists() {
406            Some(archived.as_path())
407        } else if archive_root.join(".git").exists() {
408            // Compatibility with the first lifecycle slice, which put
409            // one archived checkout directly at `<repo>/<name>`.
410            Some(archive_root.as_path())
411        } else {
412            None
413        };
414        if let Some(existing_archive) = existing_archive.filter(|_| !live.exists()) {
415            let operation = platform.archive_change_receipt(
416                &req,
417                field("change"),
418                &workspace,
419                &change.revision_id,
420                &attribution,
421            );
422            let mut response = serde_json::json!({
423                "workspace": workspace,
424                "change_id": field("change"),
425                "archived_path": existing_archive.display().to_string(),
426                "already_archived": true,
427            });
428            if let Some((seq, hash)) = operation {
429                response["operation"] = serde_json::json!({
430                    "seq": seq,
431                    "hash": hash.to_hex(),
432                    "already_applied": true,
433                });
434            }
435            return (200, response.to_string());
436        }
437        return lifecycle_conflict(
438            "the change is archived but its filesystem state is inconsistent",
439        );
440    }
441    if let Err(reason) = platform.validate_archive_change_request(
442        &req,
443        field("change"),
444        &workspace,
445        &change.revision_id,
446    ) {
447        let rejection = Rejection::decode(&reason);
448        let status = if rejection.code == Code::WorkspaceState.as_str() {
449            409
450        } else {
451            400
452        };
453        return (status, rejection.body());
454    }
455
456    if let Err(error) = migrate_legacy_archive(&archive_root, platform, &workspace, field("change"))
457    {
458        return (500, serde_json::json!({ "error": error }).to_string());
459    }
460    let recovering_rename = archived.exists() && !live.exists();
461    if !recovering_rename {
462        if !live.exists() || archived.exists() {
463            return lifecycle_conflict(
464                "the active workspace filesystem state does not match the durable view",
465            );
466        }
467        if let Err(e) = std::fs::create_dir_all(&archive_root) {
468            return (
469                500,
470                serde_json::json!({ "error": format!("create archive dir: {e}") }).to_string(),
471            );
472        }
473        if let Err(e) = std::fs::rename(&live, &archived) {
474            return (
475                500,
476                serde_json::json!({ "error": format!("archive workspace: {e}") }).to_string(),
477            );
478        }
479    }
480
481    match platform.submit_archive_change(
482        &req,
483        field("change"),
484        &workspace,
485        &change.revision_id,
486        &attribution,
487    ) {
488        Ok(accepted) => (
489            200,
490            serde_json::json!({
491                "workspace": workspace,
492                "change_id": field("change"),
493                "archived_path": archived.display().to_string(),
494                "already_archived": false,
495                "operation": {
496                    "seq": accepted.seq,
497                    "hash": accepted.hash.to_hex(),
498                },
499            })
500            .to_string(),
501        ),
502        Err(reason) => {
503            if let Err(e) = std::fs::rename(&archived, &live) {
504                return (
505                    500,
506                    serde_json::json!({
507                        "error": format!("sequencing failed and archive rollback failed: {e}"),
508                        "sequencer_error": Rejection::decode(&reason).to_json(),
509                    })
510                    .to_string(),
511                );
512            }
513            (409, Rejection::decode(&reason).body())
514        }
515    }
516}
517
518fn archive_generation(change_id: &str) -> String {
519    ContentHash::blake3(change_id.as_bytes()).to_hex()
520}
521
522fn migrate_legacy_archive(
523    archive_root: &Path,
524    platform: &Platform,
525    workspace: &str,
526    current_change: &str,
527) -> Result<(), String> {
528    if !archive_root.join(".git").exists() {
529        return Ok(());
530    }
531    let prior: Vec<String> = platform
532        .archived_change_ids_for_workspace(workspace)
533        .into_iter()
534        .filter(|id| id != current_change)
535        .collect();
536    let [prior_change] = prior.as_slice() else {
537        return Err(
538            "legacy archive cannot be assigned to exactly one prior change generation".into(),
539        );
540    };
541    let parent = archive_root.parent().expect("archive root has parent");
542    let name = archive_root
543        .file_name()
544        .and_then(|name| name.to_str())
545        .expect("validated workspace name");
546    let generation = archive_generation(prior_change);
547    let temporary = parent.join(format!(".{name}.migrating-{generation}"));
548    if temporary.exists() {
549        return Err("legacy archive migration temporary path already exists".into());
550    }
551    std::fs::rename(archive_root, &temporary)
552        .map_err(|error| format!("stage legacy archive migration: {error}"))?;
553    if let Err(error) = std::fs::create_dir_all(archive_root) {
554        std::fs::rename(&temporary, archive_root).ok();
555        return Err(format!("create versioned archive directory: {error}"));
556    }
557    let destination = archive_root.join(generation);
558    if let Err(error) = std::fs::rename(&temporary, &destination) {
559        std::fs::remove_dir(archive_root).ok();
560        std::fs::rename(&temporary, archive_root).ok();
561        return Err(format!("finish legacy archive migration: {error}"));
562    }
563    Ok(())
564}
565
566struct AdvancedBinding<'a> {
567    base_hex: &'a str,
568    owner: &'a str,
569    change_id: &'a str,
570    idempotency_key: &'a str,
571    owner_sig: choir_oplog::Witness,
572    /// Directory prefixes the owner signed alongside the binding.
573    cone: Vec<String>,
574    attribution: &'a str,
575}
576
577fn reuse_or_conflict(
578    platform: &Platform,
579    workspace: &str,
580    path: &Path,
581    binding: AdvancedBinding<'_>,
582) -> (u16, String) {
583    let AdvancedBinding {
584        base_hex,
585        owner,
586        change_id,
587        idempotency_key,
588        owner_sig,
589        cone,
590        attribution,
591    } = binding;
592    let Some(change) = platform.change_state(change_id) else {
593        return lifecycle_conflict(
594            "workspace directory exists without the requested durable change binding",
595        );
596    };
597    let base = ContentHash::from_git_oid(base_hex).expect("validated base");
598    if change.owner != owner
599        || change.workspace_id != workspace
600        || change.active_workspace.as_deref() != Some(workspace)
601        || change.base_revision != base
602        || change.idempotency_key != idempotency_key
603    {
604        return lifecycle_conflict(
605            "workspace exists with a different base, owner, change or idempotency key",
606        );
607    }
608    let Some(head) = platform.workspace_head(workspace) else {
609        return lifecycle_conflict(
610            "workspace directory and change exist but the active workspace head is missing",
611        );
612    };
613    if head != change.revision_id {
614        return lifecycle_conflict(
615            "workspace and change revisions disagree; refusing to guess which state is current",
616        );
617    }
618    let head_hex = git_oid_hex(&head).expect("workspace revisions are verified Git oids");
619    let mut response = serde_json::json!({
620        "workspace": workspace,
621        "path": path.display().to_string(),
622        "head": head_hex,
623        "base_revision": change.base_revision.to_hex(),
624        "revision_id": change.revision_id.to_hex(),
625        "change_id": change_id,
626        "owner": owner,
627        "idempotency_key": idempotency_key,
628        "created": false,
629        "reused": true,
630    });
631    let accepted = match platform.create_change(
632        AuthorizedChangeCreate {
633            id: change_id,
634            owner,
635            workspace,
636            base_hex,
637            idempotency_key,
638            owner_sig,
639            cone,
640        },
641        attribution,
642    ) {
643        Ok(accepted) => accepted,
644        Err(reason) => return (409, Rejection::decode(&reason).body()),
645    };
646    {
647        let (seq, hash) = (accepted.seq, accepted.hash);
648        response["operation"] = serde_json::json!({
649            "seq": seq,
650            "hash": hash.to_hex(),
651            "already_applied": true,
652        });
653    }
654    (200, response.to_string())
655}
656
657fn git_oid_hex(hash: &ContentHash) -> Option<String> {
658    let expected = match hash.codec {
659        0x11 => 20,
660        0x12 => 32,
661        _ => return None,
662    };
663    if hash.digest.len() != expected {
664        return None;
665    }
666    Some(
667        hash.digest
668            .iter()
669            .map(|byte| format!("{byte:02x}"))
670            .collect(),
671    )
672}
673
674fn lifecycle_conflict(error: impl Into<String>) -> (u16, String) {
675    problem(
676        409,
677        Code::WorkspaceState,
678        error,
679        "read GET /api/view and retry only with the exact durable binding, or choose a new workspace and change id",
680    )
681}
682
683fn problem(
684    status: u16,
685    code: Code,
686    error: impl Into<String>,
687    next: impl Into<String>,
688) -> (u16, String) {
689    (status, Rejection::new(code, error, next).body())
690}
691
692/// Ensures the template checkout is at `head`, then CoW-copies it to
693/// `ws_dir`. Returns the copy's wall-clock milliseconds (the number the
694/// wedge advertises; template refresh is amortized across workspaces).
695fn provision(
696    root: &Path,
697    bare: &Path,
698    repo: &str,
699    head: &str,
700    ws_dir: &Path,
701    base_url: &str,
702) -> Result<f64, String> {
703    let template: PathBuf = root.join(".choir").join("checkouts").join(repo);
704    // `TEMPLATE_CONFIG` leads every one of these: a `fetch` or `checkout`
705    // is exactly what triggers the housekeeping that races the copy
706    // below, and `clone -c` persists the same rule into a new template.
707    let with_config = |rest: &[&str]| -> Vec<String> {
708        TEMPLATE_CONFIG
709            .iter()
710            .chain(rest.iter())
711            .map(|a| (*a).to_string())
712            .collect()
713    };
714    let run = |args: Vec<String>, dir: Option<&Path>| -> Result<String, String> {
715        let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
716        git(&borrowed, dir)
717    };
718    if template.join(".git").exists() {
719        run(with_config(&["fetch", "-q", "origin"]), Some(&template))?;
720    } else {
721        std::fs::create_dir_all(template.parent().expect("has parent"))
722            .map_err(|e| format!("create template dir: {e}"))?;
723        run(
724            with_config(&[
725                "clone",
726                "-q",
727                "-c",
728                "gc.auto=0",
729                "-c",
730                "maintenance.auto=false",
731                bare.to_str().expect("utf8"),
732                template.to_str().expect("utf8"),
733            ]),
734            None,
735        )?;
736    }
737    run(
738        with_config(&["checkout", "-q", "--detach", head]),
739        Some(&template),
740    )?;
741
742    std::fs::create_dir_all(ws_dir.parent().expect("has parent"))
743        .map_err(|e| format!("create workspace dir: {e}"))?;
744    let started = std::time::Instant::now();
745    cow_copy(&template, ws_dir)?;
746    let copy_ms = started.elapsed().as_secs_f64() * 1000.0;
747
748    // Pushes from the workspace must ride the sequenced smart-HTTP
749    // path, never the bare repo's filesystem path.
750    git(
751        &[
752            "remote",
753            "set-url",
754            "origin",
755            &format!("{base_url}/{repo}.git"),
756        ],
757        Some(ws_dir),
758    )?;
759    Ok(copy_ms)
760}