Skip to main content

joy_core/
init.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use std::path::{Path, PathBuf};
5
6use crate::embedded::{self, EmbeddedFile};
7use crate::error::JoyError;
8use crate::model::project::{derive_acronym, Project};
9use crate::store;
10use crate::vcs::{default_vcs, Vcs};
11
12pub const HOOK_FILES: &[EmbeddedFile] = &[
13    EmbeddedFile {
14        content: include_str!("../data/hooks/commit-msg"),
15        target: "hooks/commit-msg",
16        executable: true,
17    },
18    EmbeddedFile {
19        content: include_str!("../data/hooks/prepare-commit-msg"),
20        target: "hooks/prepare-commit-msg",
21        executable: true,
22    },
23];
24
25pub const CONFIG_FILES: &[EmbeddedFile] = &[EmbeddedFile {
26    content: include_str!("../data/config.defaults.yaml"),
27    target: "config.defaults.yaml",
28    executable: false,
29}];
30
31pub const PROJECT_FILES: &[EmbeddedFile] = &[EmbeddedFile {
32    content: include_str!("../data/project.defaults.yaml"),
33    target: "project.defaults.yaml",
34    executable: false,
35}];
36
37/// Dead pre-ADR-024 artefacts under `.joy/`. Before ADR-024 the AI
38/// integration synced intermediate instruction/skill/capability files
39/// into `.joy/ai/` and `.joy/capabilities/`; today every template is
40/// embedded in the binary and rendered straight into the tool
41/// directories (`.claude/`, `.qwen/`, `AGENTS.md`, `.github/`). The
42/// current CLI neither reads nor writes these files, but AI tools that
43/// stumble over them in an old repo treat their content as authoritative.
44/// `joy update` and `joy ai init` remove them.
45///
46/// Paths are relative to the project root. Directories are removed
47/// recursively. The current runtime data under `.joy/ai/jobs/` and
48/// `.joy/ai/agents/` is deliberately NOT listed and stays untouched.
49pub const LEGACY_AI_ARTIFACTS: &[&str] = &[
50    ".joy/ai/instructions.md",
51    ".joy/ai/instructions",
52    ".joy/ai/skills",
53    ".joy/capabilities",
54];
55
56pub struct InitOptions {
57    pub root: PathBuf,
58    pub name: Option<String>,
59    pub acronym: Option<String>,
60    /// Override the creator member email. Falls back to git config user.email.
61    pub user: Option<String>,
62    /// Project language code (ISO 639-1, e.g. "en", "de"). Defaults to "en".
63    pub language: Option<String>,
64}
65
66#[derive(Debug)]
67pub struct InitResult {
68    pub project_dir: PathBuf,
69    pub git_initialized: bool,
70    pub git_existed: bool,
71}
72
73pub struct OnboardResult {
74    pub hooks_installed: bool,
75    pub hooks_already_set: bool,
76}
77
78pub fn init(options: InitOptions) -> Result<InitResult, JoyError> {
79    let root = &options.root;
80    let joy_dir = store::joy_dir(root);
81
82    if store::is_initialized(root) {
83        return Err(JoyError::AlreadyInitialized(joy_dir));
84    }
85
86    // Detect or initialize git
87    let vcs = default_vcs();
88    let git_existed = vcs.is_repo(root);
89    let mut git_initialized = false;
90    if !git_existed {
91        vcs.init_repo(root)?;
92        git_initialized = true;
93    }
94
95    // Create directory structure
96    let dirs = [
97        store::ITEMS_DIR,
98        store::MILESTONES_DIR,
99        store::RELEASES_DIR,
100        store::AI_AGENTS_DIR,
101        store::AI_JOBS_DIR,
102        store::LOG_DIR,
103    ];
104    for dir in &dirs {
105        let path = joy_dir.join(dir);
106        std::fs::create_dir_all(&path).map_err(|e| JoyError::CreateDir {
107            path: path.clone(),
108            source: e,
109        })?;
110    }
111
112    // Derive project name and acronym
113    let name = options.name.unwrap_or_else(|| {
114        root.file_name()
115            .and_then(|n| n.to_str())
116            .unwrap_or("project")
117            .to_string()
118    });
119    let acronym = options.acronym.unwrap_or_else(|| derive_acronym(&name));
120
121    // Write config and project defaults (embedded files)
122    embedded::sync_files(root, CONFIG_FILES)?;
123    embedded::sync_files(root, PROJECT_FILES)?;
124
125    let mut project = Project::new(name, Some(acronym));
126    if let Some(lang) = options.language.filter(|s| !s.is_empty()) {
127        project.language = lang;
128    }
129
130    // Register the project creator as a member with all capabilities.
131    // Prefer an explicit override, fall back to git config user.email.
132    let creator_email = options
133        .user
134        .filter(|s| !s.is_empty())
135        .or_else(|| vcs.user_email().ok().filter(|s| !s.is_empty()));
136    if let Some(email) = creator_email {
137        project.members.insert(
138            email,
139            crate::model::project::Member::new(crate::model::project::MemberCapabilities::All),
140        );
141    }
142
143    store::write_yaml(&joy_dir.join(store::PROJECT_FILE), &project)?;
144    let project_rel = format!("{}/{}", store::JOY_DIR, store::PROJECT_FILE);
145    let defaults_rel = format!("{}/{}", store::JOY_DIR, store::CONFIG_DEFAULTS_FILE);
146    crate::git_ops::auto_git_add(root, &[&project_rel, &defaults_rel]);
147
148    // Ensure .joy/credentials.yaml is in .gitignore
149    ensure_gitignore(root)?;
150
151    // Register the YAML / log merge driver in .gitattributes and git config.
152    ensure_gitattributes(root)?;
153    register_merge_driver(root)?;
154
155    // Install hooks
156    install_hooks(root)?;
157
158    // Stamp the per-clone version marker so the first joy invocation
159    // after init does not re-trigger the auto-sync routine. The CLI
160    // caller is responsible for the cargo-pkg version string; we use
161    // the joy-core version here as a stable proxy.
162    let _ = set_last_sync_version(root, env!("CARGO_PKG_VERSION"));
163
164    Ok(InitResult {
165        project_dir: joy_dir,
166        git_initialized,
167        git_existed,
168    })
169}
170
171/// Onboard an existing project: set up local environment (hooks, etc.).
172pub fn onboard(root: &Path) -> Result<OnboardResult, JoyError> {
173    embedded::sync_files(root, CONFIG_FILES)?;
174    embedded::sync_files(root, PROJECT_FILES)?;
175    ensure_gitignore(root)?;
176    ensure_gitattributes(root)?;
177    register_merge_driver(root)?;
178    let result = install_hooks(root)?;
179    // Stamp the per-clone marker so the first joy invocation after
180    // onboard does not re-trigger the auto-sync routine.
181    let _ = set_last_sync_version(root, env!("CARGO_PKG_VERSION"));
182    Ok(result)
183}
184
185/// Sync hook files and set core.hooksPath.
186fn install_hooks(root: &Path) -> Result<OnboardResult, JoyError> {
187    let actions = embedded::sync_files(root, HOOK_FILES)?;
188    let hooks_installed = actions.iter().any(|a| a.action != "up to date");
189
190    // Set core.hooksPath if not already pointing to .joy/hooks
191    let vcs = default_vcs();
192    let current = vcs.config_get(root, "core.hooksPath").unwrap_or_default();
193    let already_set = current == ".joy/hooks";
194
195    if !already_set {
196        vcs.config_set(root, "core.hooksPath", ".joy/hooks")?;
197    }
198
199    Ok(OnboardResult {
200        hooks_installed,
201        hooks_already_set: already_set,
202    })
203}
204
205pub const GITIGNORE_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
206pub const GITIGNORE_BLOCK_END: &str = "### joy:end";
207
208pub const GITIGNORE_BASE_ENTRIES: &[(&str, &str)] = &[
209    (".joy/config.yaml", "personal config"),
210    (".joy/credentials.yaml", "secrets"),
211    (".joy/hooks/", "git hooks"),
212    (".joy/project.defaults.yaml", "embedded project defaults"),
213];
214
215/// Update the joy-managed block in .gitignore with the given entries.
216/// Each entry is (path, comment). Replaces the block if it exists, appends otherwise.
217pub fn update_gitignore_block(root: &Path, entries: &[(&str, &str)]) -> Result<(), JoyError> {
218    let gitignore_path = root.join(".gitignore");
219
220    let mut lines = String::new();
221    for (path, _comment) in entries {
222        lines.push_str(path);
223        lines.push('\n');
224    }
225    let block = format!(
226        "{}\n{}{}",
227        GITIGNORE_BLOCK_START, lines, GITIGNORE_BLOCK_END
228    );
229
230    let content = if gitignore_path.is_file() {
231        let existing =
232            std::fs::read_to_string(&gitignore_path).map_err(|e| JoyError::ReadFile {
233                path: gitignore_path.clone(),
234                source: e,
235            })?;
236        if existing.contains(GITIGNORE_BLOCK_START) && existing.contains(GITIGNORE_BLOCK_END) {
237            let start = existing.find(GITIGNORE_BLOCK_START).unwrap();
238            let end = existing.find(GITIGNORE_BLOCK_END).unwrap() + GITIGNORE_BLOCK_END.len();
239            let mut updated = String::new();
240            updated.push_str(&existing[..start]);
241            updated.push_str(&block);
242            updated.push_str(&existing[end..]);
243            updated
244        } else {
245            let trimmed = existing.trim_end();
246            if trimmed.is_empty() {
247                format!("{}\n", block)
248            } else {
249                format!("{}\n\n{}\n", trimmed, block)
250            }
251        }
252    } else {
253        format!("{}\n", block)
254    };
255
256    // Idempotency: skip write + auto-stage when content already matches.
257    if gitignore_path.is_file() {
258        if let Ok(existing) = std::fs::read_to_string(&gitignore_path) {
259            if existing == content {
260                return Ok(());
261            }
262        }
263    }
264
265    std::fs::write(&gitignore_path, &content).map_err(|e| JoyError::WriteFile {
266        path: gitignore_path,
267        source: e,
268    })?;
269    crate::git_ops::auto_git_add(root, &[".gitignore"]);
270    Ok(())
271}
272
273fn ensure_gitignore(root: &Path) -> Result<(), JoyError> {
274    update_gitignore_block(root, GITIGNORE_BASE_ENTRIES)
275}
276
277pub const GITATTRIBUTES_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
278pub const GITATTRIBUTES_BLOCK_END: &str = "### joy:end";
279
280/// Path-pattern -> Git attribute lines for the joy-managed
281/// `.gitattributes` block. The YAML driver covers every Joy YAML file
282/// (items, milestones, releases, project metadata). The log entry uses
283/// Git's built-in union driver as an interim until JOY-0112 (Merkle-DAG
284/// log) ships.
285pub const GITATTRIBUTES_BASE_ENTRIES: &[&str] = &[
286    ".joy/items/*.yaml merge=joy-yaml",
287    ".joy/milestones/*.yaml merge=joy-yaml",
288    ".joy/releases/*.yaml merge=joy-yaml",
289    ".joy/ai/agents/*.yaml merge=joy-yaml",
290    ".joy/ai/jobs/*.yaml merge=joy-yaml",
291    ".joy/project.yaml merge=joy-yaml",
292    ".joy/config.defaults.yaml merge=joy-yaml",
293    ".joy/logs/*.log merge=union",
294];
295
296pub const MERGE_DRIVER_NAME_KEY: &str = "merge.joy-yaml.name";
297pub const MERGE_DRIVER_NAME_VALUE: &str = "Joy YAML merge driver";
298pub const MERGE_DRIVER_CMD_KEY: &str = "merge.joy-yaml.driver";
299pub const MERGE_DRIVER_CMD_VALUE: &str =
300    "joy merge driver --base %O --current %A --other %B --path %P --ours-rev %X --theirs-rev %Y";
301
302/// Update the joy-managed block in .gitattributes with the given lines.
303/// Replaces the block if it exists, appends otherwise.
304pub fn update_gitattributes_block(root: &Path, lines: &[&str]) -> Result<(), JoyError> {
305    let path = root.join(".gitattributes");
306
307    let mut joined = String::new();
308    for line in lines {
309        joined.push_str(line);
310        joined.push('\n');
311    }
312    let block = format!(
313        "{}\n{}{}",
314        GITATTRIBUTES_BLOCK_START, joined, GITATTRIBUTES_BLOCK_END
315    );
316
317    let content = if path.is_file() {
318        let existing = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
319            path: path.clone(),
320            source: e,
321        })?;
322        if existing.contains(GITATTRIBUTES_BLOCK_START)
323            && existing.contains(GITATTRIBUTES_BLOCK_END)
324        {
325            let start = existing.find(GITATTRIBUTES_BLOCK_START).unwrap();
326            let end =
327                existing.find(GITATTRIBUTES_BLOCK_END).unwrap() + GITATTRIBUTES_BLOCK_END.len();
328            let mut updated = String::new();
329            updated.push_str(&existing[..start]);
330            updated.push_str(&block);
331            updated.push_str(&existing[end..]);
332            updated
333        } else {
334            let trimmed = existing.trim_end();
335            if trimmed.is_empty() {
336                format!("{}\n", block)
337            } else {
338                format!("{}\n\n{}\n", trimmed, block)
339            }
340        }
341    } else {
342        format!("{}\n", block)
343    };
344
345    // Idempotency: skip write + auto-stage when content already matches.
346    // The lazy-activation hook (called on every joy invocation) relies on
347    // this short-circuit to stay cheap and to not dirty the working tree.
348    if path.is_file() {
349        if let Ok(existing) = std::fs::read_to_string(&path) {
350            if existing == content {
351                return Ok(());
352            }
353        }
354    }
355
356    std::fs::write(&path, &content).map_err(|e| JoyError::WriteFile { path, source: e })?;
357    crate::git_ops::auto_git_add(root, &[".gitattributes"]);
358    Ok(())
359}
360
361fn ensure_gitattributes(root: &Path) -> Result<(), JoyError> {
362    update_gitattributes_block(root, GITATTRIBUTES_BASE_ENTRIES)
363}
364
365/// Best-effort registration check, called before every joy invocation
366/// that has a project root. Brings `.gitattributes` and the local git
367/// merge-driver config in line with the current binary, so users who
368/// upgraded joy without re-running `joy init` still get the merge
369/// driver. See JOY-0162.
370///
371/// Idempotent and silent: the file write is skipped when the block is
372/// already up to date, and `register_merge_driver` only writes when the
373/// stored values differ.
374pub fn ensure_lazy_activation(root: &Path) -> Result<(), JoyError> {
375    let vcs = default_vcs();
376    if !vcs.is_repo(root) {
377        return Ok(());
378    }
379    ensure_gitattributes(root)?;
380    register_merge_driver(root)?;
381    Ok(())
382}
383
384/// Per-clone git config key recording the joy version that last synced
385/// this repo. Compared against `env!("CARGO_PKG_VERSION")` to drive the
386/// auto-sync hook. See JOY-0164-B5.
387pub const LAST_SYNC_VERSION_KEY: &str = "joy.last-sync-version";
388
389/// Read the recorded last-sync version from this clone's git config.
390/// `None` if not a repo or the key is unset.
391///
392/// TODO: route through a `Vcs::config_get` trait method so non-Git
393/// backends can implement (ADR-010). Today it's a `GitVcs` inherent
394/// method, which constrains the abstraction.
395pub fn last_sync_version(root: &Path) -> Option<String> {
396    let vcs = default_vcs();
397    if !vcs.is_repo(root) {
398        return None;
399    }
400    vcs.config_get(root, LAST_SYNC_VERSION_KEY).ok()
401}
402
403/// Stamp the current binary version into this clone's git config.
404pub fn set_last_sync_version(root: &Path, version: &str) -> Result<(), JoyError> {
405    let vcs = default_vcs();
406    if !vcs.is_repo(root) {
407        return Ok(());
408    }
409    vcs.config_set(root, LAST_SYNC_VERSION_KEY, version)
410}
411
412/// One-shot core-side sync of a repo against the current binary:
413/// `ensure_lazy_activation` + stamp `joy.last-sync-version`. The full
414/// `joy update` orchestrator wraps this with the auth and AI refresh
415/// routines (see joy-cli's `commands::update::run_full_sync`).
416pub fn run_sync(root: &Path, current_version: &str) -> Result<(), JoyError> {
417    ensure_lazy_activation(root)?;
418    set_last_sync_version(root, current_version)
419}
420
421/// Register the joy-yaml merge driver in the local Git config. Idempotent:
422/// repeated calls overwrite with the same value. The config is per-clone
423/// (Git does not transmit it through clone), so this is also called from
424/// `onboard` to bring fresh clones up to date.
425fn register_merge_driver(root: &Path) -> Result<(), JoyError> {
426    let vcs = default_vcs();
427    if !vcs.is_repo(root) {
428        return Ok(());
429    }
430    if vcs.config_get(root, MERGE_DRIVER_NAME_KEY).ok().as_deref() != Some(MERGE_DRIVER_NAME_VALUE)
431    {
432        vcs.config_set(root, MERGE_DRIVER_NAME_KEY, MERGE_DRIVER_NAME_VALUE)?;
433    }
434    if vcs.config_get(root, MERGE_DRIVER_CMD_KEY).ok().as_deref() != Some(MERGE_DRIVER_CMD_VALUE) {
435        vcs.config_set(root, MERGE_DRIVER_CMD_KEY, MERGE_DRIVER_CMD_VALUE)?;
436    }
437    Ok(())
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use tempfile::tempdir;
444
445    #[test]
446    fn init_creates_directory_structure() {
447        let dir = tempdir().unwrap();
448        let result = init(InitOptions {
449            root: dir.path().to_path_buf(),
450            name: Some("Test Project".into()),
451            acronym: Some("TP".into()),
452            user: None,
453            language: None,
454        })
455        .unwrap();
456
457        assert!(result.project_dir.join("items").is_dir());
458        assert!(result.project_dir.join("milestones").is_dir());
459        assert!(result.project_dir.join("ai/agents").is_dir());
460        assert!(result.project_dir.join("ai/jobs").is_dir());
461        assert!(result.project_dir.join("logs").is_dir());
462        assert!(result.project_dir.join("config.defaults.yaml").is_file());
463        assert!(result.project_dir.join("project.yaml").is_file());
464    }
465
466    #[test]
467    fn init_writes_project_metadata() {
468        let dir = tempdir().unwrap();
469        init(InitOptions {
470            root: dir.path().to_path_buf(),
471            name: Some("My App".into()),
472            acronym: Some("MA".into()),
473            user: None,
474            language: None,
475        })
476        .unwrap();
477
478        let project: Project =
479            store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
480        assert_eq!(project.name, "My App");
481        assert_eq!(project.acronym.as_deref(), Some("MA"));
482    }
483
484    #[test]
485    fn init_derives_name_from_directory() {
486        let dir = tempdir().unwrap();
487        init(InitOptions {
488            root: dir.path().to_path_buf(),
489            name: None,
490            acronym: None,
491            user: None,
492            language: None,
493        })
494        .unwrap();
495
496        let project: Project =
497            store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
498        // tempdir names vary, just check it's not empty
499        assert!(!project.name.is_empty());
500        assert!(project.acronym.is_some());
501    }
502
503    #[test]
504    fn init_fails_if_already_initialized() {
505        let dir = tempdir().unwrap();
506        init(InitOptions {
507            root: dir.path().to_path_buf(),
508            name: Some("Test".into()),
509            acronym: None,
510            user: None,
511            language: None,
512        })
513        .unwrap();
514
515        let err = init(InitOptions {
516            root: dir.path().to_path_buf(),
517            name: Some("Test".into()),
518            acronym: None,
519            user: None,
520            language: None,
521        })
522        .unwrap_err();
523
524        assert!(matches!(err, JoyError::AlreadyInitialized(_)));
525    }
526
527    #[test]
528    fn init_creates_gitignore_with_credentials_entry() {
529        let dir = tempdir().unwrap();
530        init(InitOptions {
531            root: dir.path().to_path_buf(),
532            name: Some("Test".into()),
533            acronym: None,
534            user: None,
535            language: None,
536        })
537        .unwrap();
538
539        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
540        assert!(content.contains(".joy/credentials.yaml"));
541        assert!(content.contains(".joy/config.yaml"));
542    }
543
544    #[test]
545    fn init_does_not_duplicate_gitignore_block() {
546        let dir = tempdir().unwrap();
547        // First init creates the block
548        init(InitOptions {
549            root: dir.path().to_path_buf(),
550            name: Some("Test".into()),
551            acronym: None,
552            user: None,
553            language: None,
554        })
555        .unwrap();
556        let first = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
557
558        // Re-running ensure_gitignore should not duplicate
559        super::ensure_gitignore(dir.path()).unwrap();
560        let second = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
561
562        assert_eq!(first, second);
563        assert_eq!(second.matches(GITIGNORE_BLOCK_START).count(), 1);
564    }
565
566    #[test]
567    fn init_writes_gitattributes_block_with_joy_yaml_and_union_log() {
568        let dir = tempdir().unwrap();
569        init(InitOptions {
570            root: dir.path().to_path_buf(),
571            name: Some("Test".into()),
572            acronym: None,
573            user: None,
574            language: None,
575        })
576        .unwrap();
577
578        let content = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
579        assert!(content.contains(GITATTRIBUTES_BLOCK_START));
580        assert!(content.contains(GITATTRIBUTES_BLOCK_END));
581        assert!(content.contains(".joy/items/*.yaml merge=joy-yaml"));
582        assert!(content.contains(".joy/milestones/*.yaml merge=joy-yaml"));
583        assert!(content.contains(".joy/releases/*.yaml merge=joy-yaml"));
584        assert!(content.contains(".joy/ai/agents/*.yaml merge=joy-yaml"));
585        assert!(content.contains(".joy/ai/jobs/*.yaml merge=joy-yaml"));
586        assert!(content.contains(".joy/project.yaml merge=joy-yaml"));
587        assert!(content.contains(".joy/config.defaults.yaml merge=joy-yaml"));
588        assert!(content.contains(".joy/logs/*.log merge=union"));
589    }
590
591    #[test]
592    fn init_does_not_duplicate_gitattributes_block() {
593        let dir = tempdir().unwrap();
594        init(InitOptions {
595            root: dir.path().to_path_buf(),
596            name: Some("Test".into()),
597            acronym: None,
598            user: None,
599            language: None,
600        })
601        .unwrap();
602        let first = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
603
604        super::ensure_gitattributes(dir.path()).unwrap();
605        let second = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
606
607        assert_eq!(first, second);
608        assert_eq!(second.matches(GITATTRIBUTES_BLOCK_START).count(), 1);
609    }
610
611    #[test]
612    fn init_registers_merge_driver_in_git_config() {
613        let dir = tempdir().unwrap();
614        init(InitOptions {
615            root: dir.path().to_path_buf(),
616            name: Some("Test".into()),
617            acronym: None,
618            user: None,
619            language: None,
620        })
621        .unwrap();
622
623        let vcs = default_vcs();
624        let name = vcs.config_get(dir.path(), MERGE_DRIVER_NAME_KEY).unwrap();
625        let cmd = vcs.config_get(dir.path(), MERGE_DRIVER_CMD_KEY).unwrap();
626        assert_eq!(name, MERGE_DRIVER_NAME_VALUE);
627        assert_eq!(cmd, MERGE_DRIVER_CMD_VALUE);
628        assert!(cmd.contains("--ours-rev %X"));
629        assert!(cmd.contains("--theirs-rev %Y"));
630    }
631
632    #[test]
633    fn init_initializes_git_if_needed() {
634        let dir = tempdir().unwrap();
635        let result = init(InitOptions {
636            root: dir.path().to_path_buf(),
637            name: Some("Test".into()),
638            acronym: None,
639            user: None,
640            language: None,
641        })
642        .unwrap();
643
644        assert!(result.git_initialized);
645        assert!(!result.git_existed);
646        assert!(dir.path().join(".git").is_dir());
647    }
648}