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 <noreply@anthropic.com>
This commit is contained in:
landon
2026-07-28 11:58:47 -05:00
parent 496bbfd298
commit b31ac0c6c8
3 changed files with 151 additions and 5 deletions
+6
View File
@@ -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
+125 -4
View File
@@ -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<PathBuf>,
/// 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<PathBuf>,
/// 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<String, usize>,
@@ -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<String, usize> {
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>) {
+20 -1
View File
@@ -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<PathBuf> {
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"]);