From b31ac0c6c833333a374d97e4cbeca986d89bad8b Mon Sep 17 00:00:00 2001 From: landon Date: Tue, 28 Jul 2026 11:58:47 -0500 Subject: [PATCH] Offer to adopt an enclosing git repository when opening a subfolder When a workspace folder isn't a git repo itself but sits inside one, ask the user to confirm before using the parent repository for Sync, rather than silently operating on whichever repo git walks up to find. - gitsync: add repo_root() via `git rev-parse --show-toplevel` - app: detect_repo() distinguishes repo-root / parent-repo / no-repo; a confirmation dialog adopts or declines the parent repo; git_init tracks repo_root and clears any pending prompt - Sync button hover names the enclosing repo when one is adopted Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +++ src/app.rs | 129 +++++++++++++++++++++++++++++++++++++++++++++++-- src/gitsync.rs | 21 +++++++- 3 files changed, 151 insertions(+), 5 deletions(-) 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"]);