diff --git a/README.md b/README.md index 73bf63d..26a8ca4 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ Terminal=false ``` After that, the **⟳ Sync (git)** button commits all changes, `pull --rebase`s, and pushes. + + If you open a folder that is *not* itself a repository but sits inside one + (a parent directory is a git work tree), the app asks whether to **use the + enclosing repository**. Accept it to have **⟳ Sync** commit and push to that + repo; decline to leave the workspace on its own, where **Init git** creates a + separate repository in the folder. 3. Create files with **+ New**, edit on the right, `Ctrl+S` (or the Save button) to write to disk. Drag the `⠿` handles to reorder. Use the **Zoom** slider above the editor to scale the editor text (0% = default; **Reset** returns to diff --git a/src/app.rs b/src/app.rs index 1e5cd21..6eb0db3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,6 +36,13 @@ pub struct App { show_log: bool, git_log: String, is_repo: bool, + /// The git work tree currently backing the workspace, if any. Equals the + /// workspace when the folder is itself a repo root; a parent directory when + /// the user has adopted an enclosing repository. + repo_root: Option, + /// A repository found in a parent directory that is awaiting the user's + /// confirmation before it is adopted for the current workspace. + pending_repo: Option, /// Each file's word count captured when the workspace was opened, used as the /// per-file baseline for the "this session" delta. session_start_counts: HashMap, @@ -78,6 +85,8 @@ impl App { show_log: false, git_log: String::new(), is_repo: false, + repo_root: None, + pending_repo: None, session_start_counts: HashMap::new(), lt_matches: Vec::new(), lt_checked_text: String::new(), @@ -108,7 +117,7 @@ impl App { self.titles = order::read_titles(&ws); // Drop overrides for files that no longer exist. self.titles.retain(|name, _| self.files.contains(name)); - self.is_repo = gitsync::is_repo(&ws); + self.detect_repo(&ws); self.selected = None; self.buffer.clear(); self.dirty = false; @@ -122,6 +131,61 @@ impl App { self.status = format!("{} file(s) in {}", self.files.len(), ws.display()); } + /// Work out the git situation for a freshly opened workspace. + /// + /// If the folder is itself a repository root it is adopted silently. If it + /// is not, but an enclosing parent directory is a git work tree, we stash + /// that parent in `pending_repo` and ask the user to confirm before using + /// it (see [`repo_prompt`](Self::repo_prompt)). Otherwise the workspace is + /// treated as having no repository. + fn detect_repo(&mut self, ws: &Path) { + self.pending_repo = None; + // A `.git` entry directly in the folder (dir, or a file for linked + // worktrees/submodules) means this folder is the repo root. + if ws.join(".git").exists() { + self.is_repo = true; + self.repo_root = Some(ws.to_path_buf()); + return; + } + match gitsync::repo_root(ws) { + // A parent directory is a repository — ask before adopting it. + Some(root) if root != ws => { + self.is_repo = false; + self.repo_root = None; + self.pending_repo = Some(root); + } + // `--show-toplevel` reported this very folder (shouldn't happen + // without a `.git` here, but treat it as an ordinary repo root). + Some(root) => { + self.is_repo = true; + self.repo_root = Some(root); + } + None => { + self.is_repo = false; + self.repo_root = None; + } + } + } + + /// Adopt the parent repository awaiting confirmation, using it for all git + /// operations on the current workspace. + fn adopt_pending_repo(&mut self) { + if let Some(root) = self.pending_repo.take() { + self.status = format!("Using git repository at {}", root.display()); + self.is_repo = true; + self.repo_root = Some(root); + } + } + + /// Decline the parent repository; the workspace stays without version + /// control (the user can still `Init git` to create a nested repo). + fn decline_pending_repo(&mut self) { + self.pending_repo = None; + self.is_repo = false; + self.repo_root = None; + self.status = "Not using the enclosing git repository".to_string(); + } + /// Read every file and return its current on-disk word count. fn snapshot_counts(&self) -> HashMap { self.files @@ -305,10 +369,15 @@ impl App { } fn git_init(&mut self) { - let outcome = gitsync::init(self.workspace()); + // Initialising creates a repository in the workspace itself, which + // supersedes any enclosing repo we were about to ask about. + self.pending_repo = None; + let ws = self.workspace().to_path_buf(); + let outcome = gitsync::init(&ws); self.git_log = outcome.log; self.show_log = true; - self.is_repo = gitsync::is_repo(self.workspace()); + self.is_repo = gitsync::is_repo(&ws); + self.repo_root = self.is_repo.then_some(ws); self.status = if self.is_repo { "Initialised git repository".to_string() } else { @@ -609,7 +678,13 @@ impl App { } ui.separator(); if self.is_repo { - if ui.button("⟳ Sync (git)").clicked() { + let hover = match &self.repo_root { + Some(root) if root.as_path() != self.workspace() => { + format!("Commit & push to the repository at {}", root.display()) + } + _ => "Commit & push this workspace's repository".to_string(), + }; + if ui.button("⟳ Sync (git)").on_hover_text(hover).clicked() { self.git_sync(); } } else if ui.button("Init git").clicked() { @@ -1244,6 +1319,48 @@ impl App { } }); } + + /// Ask the user whether to adopt a git repository found in a parent + /// directory of the freshly opened workspace. + fn repo_prompt(&mut self, ctx: &egui::Context) { + let Some(root) = self.pending_repo.clone() else { + return; + }; + let mut adopt = false; + let mut decline = false; + egui::Window::new("Use enclosing git repository?") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label( + "This folder isn't a git repository, but one was found in a \ + parent directory:", + ); + ui.add_space(4.0); + ui.label(egui::RichText::new(root.display().to_string()).strong()); + ui.add_space(4.0); + ui.label( + "Use it for version control (Sync commits and pushes to this \ + repo)? Otherwise the workspace is left without git; you can \ + still Init a separate repository here.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Use this repository").clicked() { + adopt = true; + } + if ui.button("No, keep separate").clicked() { + decline = true; + } + }); + }); + if adopt { + self.adopt_pending_repo(); + } else if decline { + self.decline_pending_repo(); + } + } } impl eframe::App for App { @@ -1293,6 +1410,10 @@ impl eframe::App for App { if self.show_settings { self.settings_window(ctx); } + + if self.pending_repo.is_some() { + self.repo_prompt(ctx); + } } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { diff --git a/src/gitsync.rs b/src/gitsync.rs index 340506e..9f4f67a 100644 --- a/src/gitsync.rs +++ b/src/gitsync.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; /// Result of a git operation: combined human-readable log for display. @@ -33,6 +33,25 @@ pub fn is_repo(workspace: &Path) -> bool { ok && out.contains("true") } +/// The top-level directory of the git work tree containing `workspace`, if any. +/// +/// This walks up the directory tree the way git itself does, so it finds a +/// repository whose `.git` lives in a *parent* of `workspace`, not just one +/// rooted at `workspace`. +pub fn repo_root(workspace: &Path) -> Option { + let (ok, out) = run_git(workspace, &["rev-parse", "--show-toplevel"]); + if !ok { + return None; + } + // run_git prepends a `$ git …` echo line; the toplevel path is the first + // real output line. + let line = out + .lines() + .find(|l| !l.starts_with("$ git") && !l.trim().is_empty())?; + let path = PathBuf::from(line.trim()); + (!path.as_os_str().is_empty()).then_some(path) +} + /// True if a remote named `origin` is configured. fn has_origin(workspace: &Path) -> bool { let (ok, out) = run_git(workspace, &["remote"]);