Let the git remote be set from Settings
Attaching a workspace to an existing repository meant leaving for a terminal: gitsync could init, commit and push, and could tell whether an 'origin' existed, but had no way to create one, and nothing in the settings held a remote URL. Sync just logged "no 'origin' remote configured" and committed locally. Settings > Git remote... now shows the workspace repository's origin and lets it be set, repointed or cleared. The URL is read from and written to the repository, never to config.json, because a remote belongs to the checkout — so each workspace shows its own, and the box is refreshed whenever the repository in play changes. This is separate from the Gitea publishing in New project, which is a cookiecutter post-gen hook and only ever applies to generated projects. Also factors out first_output_line, which repo_root and the new origin_url were both open-coding, and origin_args, so the add-vs-set-url decision can be tested without a repository on disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
@@ -63,14 +63,21 @@ Terminal=false
|
||||
or click **📂** to pick a folder in a native file browser — and press **Open**.
|
||||
The directory is created if it does not exist.
|
||||
2. Press **Init git** once to make the workspace a git repository. To sync with
|
||||
another machine, add a remote yourself:
|
||||
another machine, point it at a remote through **Settings ▸ Git remote…**:
|
||||
paste the repository's clone URL into the **origin** box and press **Set
|
||||
origin**. The repository has to exist on the server already — this attaches
|
||||
to it, it does not create it. Clearing the box and pressing Set detaches
|
||||
again. The equivalent from a terminal, if you prefer:
|
||||
```sh
|
||||
cd ~/Manuscript
|
||||
git remote add origin <url>
|
||||
git push -u origin main
|
||||
```
|
||||
Either way the URL lives in the repository's own `.git/config`, not in this
|
||||
app's settings, so every workspace keeps its own.
|
||||
|
||||
After that, the **⟳ Sync (git)** button commits all changes, `pull --rebase`s,
|
||||
and pushes.
|
||||
and pushes (setting the upstream on the first push). Until origin is set it
|
||||
commits locally and says so in the log.
|
||||
|
||||
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
|
||||
@@ -509,6 +516,14 @@ push command alone and is not written into the project's `.git/config`.
|
||||
The token is stored in `config.json` in plain text, the same as the Mistral API
|
||||
key.
|
||||
|
||||
Note that this is the *only* place the app creates a remote repository, and it
|
||||
runs entirely inside the template's hook — the app itself never talks to Gitea.
|
||||
So it applies to projects made through **New project** and nothing else, and it
|
||||
does nothing if **Run the template's hooks** is off, if any of the three Gitea
|
||||
settings is blank, or if the template has no such hook. For any workspace that
|
||||
did not come from the template, attach the remote with **Settings ▸ Git
|
||||
remote…** instead.
|
||||
|
||||
## New files from a template
|
||||
|
||||
The file list has two create buttons:
|
||||
|
||||
@@ -203,6 +203,12 @@ pub struct App {
|
||||
rename_focus: bool,
|
||||
/// Whether the delete confirmation opened from that menu is up.
|
||||
confirm_delete: bool,
|
||||
/// Editable copy of the workspace repository's `origin` URL. Read back from
|
||||
/// git whenever the repository changes, never persisted to the config —
|
||||
/// the remote belongs to the checkout, not to the app.
|
||||
remote_input: String,
|
||||
/// Whether the git-remote dialog is up.
|
||||
show_git_remote: bool,
|
||||
status: String,
|
||||
show_log: bool,
|
||||
git_log: String,
|
||||
@@ -371,6 +377,8 @@ impl App {
|
||||
show_rename: false,
|
||||
rename_focus: false,
|
||||
confirm_delete: false,
|
||||
remote_input: String::new(),
|
||||
show_git_remote: false,
|
||||
status: String::new(),
|
||||
show_log: false,
|
||||
git_log: String::new(),
|
||||
@@ -534,6 +542,10 @@ impl eframe::App for App {
|
||||
self.confirm_delete_window(ctx);
|
||||
}
|
||||
|
||||
if self.show_git_remote {
|
||||
self.git_remote_window(ctx);
|
||||
}
|
||||
|
||||
if self.show_new_project {
|
||||
self.new_project_window(ctx);
|
||||
}
|
||||
|
||||
@@ -424,6 +424,10 @@ impl App {
|
||||
ui.close_menu();
|
||||
self.show_project_settings = true;
|
||||
}
|
||||
if ui.button("Git remote…").clicked() {
|
||||
ui.close_menu();
|
||||
self.open_git_remote();
|
||||
}
|
||||
if ui.button("Manuscript details…").clicked() {
|
||||
ui.close_menu();
|
||||
self.show_manuscript_settings = true;
|
||||
|
||||
@@ -137,6 +137,7 @@ impl App {
|
||||
if ws.join(".git").exists() {
|
||||
self.is_repo = true;
|
||||
self.repo_root = Some(ws.to_path_buf());
|
||||
self.remote_input = gitsync::origin_url(ws).unwrap_or_default();
|
||||
return;
|
||||
}
|
||||
match gitsync::repo_root(ws) {
|
||||
@@ -151,10 +152,12 @@ impl App {
|
||||
Some(root) => {
|
||||
self.is_repo = true;
|
||||
self.repo_root = Some(root);
|
||||
self.remote_input = gitsync::origin_url(ws).unwrap_or_default();
|
||||
}
|
||||
None => {
|
||||
self.is_repo = false;
|
||||
self.repo_root = None;
|
||||
self.remote_input.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +169,7 @@ impl App {
|
||||
self.status = format!("Using git repository at {}", root.display());
|
||||
self.is_repo = true;
|
||||
self.repo_root = Some(root);
|
||||
self.remote_input = gitsync::origin_url(self.workspace()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,6 +664,7 @@ impl App {
|
||||
self.git_log = outcome.log;
|
||||
self.show_log = true;
|
||||
self.is_repo = gitsync::is_repo(&ws);
|
||||
self.remote_input = gitsync::origin_url(&ws).unwrap_or_default();
|
||||
self.repo_root = self.is_repo.then_some(ws);
|
||||
self.status = if self.is_repo {
|
||||
"Initialised git repository".to_string()
|
||||
@@ -1025,6 +1030,111 @@ impl App {
|
||||
}
|
||||
|
||||
/// Settings dialog for the markdown seeded into template-backed new files.
|
||||
/// Open the git-remote dialog, reading the current `origin` fresh so the
|
||||
/// box shows what the repository actually holds.
|
||||
pub(super) fn open_git_remote(&mut self) {
|
||||
self.remote_input = gitsync::origin_url(self.workspace()).unwrap_or_default();
|
||||
self.show_git_remote = true;
|
||||
}
|
||||
|
||||
/// Point the workspace's repository at the typed remote.
|
||||
///
|
||||
/// Nothing about the URL is kept in this app's config: it is written to the
|
||||
/// repository and read straight back, so what the box shows afterwards is
|
||||
/// what git holds, and a different project shows its own.
|
||||
pub(super) fn set_git_remote(&mut self) {
|
||||
let ws = self.workspace().to_path_buf();
|
||||
if !self.is_repo {
|
||||
self.status = "Not a git repository yet \u{2014} use \u{201c}Init git\u{201d} first"
|
||||
.to_string();
|
||||
return;
|
||||
}
|
||||
let outcome = gitsync::set_origin(&ws, &self.remote_input);
|
||||
self.git_log = outcome.log;
|
||||
self.show_log = true;
|
||||
self.remote_input = gitsync::origin_url(&ws).unwrap_or_default();
|
||||
self.status = if !outcome.ok {
|
||||
"Could not set the remote (see log)".to_string()
|
||||
} else if self.remote_input.is_empty() {
|
||||
"Removed the origin remote".to_string()
|
||||
} else {
|
||||
format!("origin is now {}", self.remote_input)
|
||||
};
|
||||
}
|
||||
|
||||
/// The dialog for attaching a workspace to an existing remote repository.
|
||||
///
|
||||
/// Creating the repository on the server is a template hook's job, and only
|
||||
/// happens for projects made through New project. Anything else — a
|
||||
/// workspace that predates the app, or one whose hook did not run — needs
|
||||
/// the remote set by hand, which otherwise meant leaving for a terminal.
|
||||
pub(super) fn git_remote_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_git_remote;
|
||||
let mut apply = false;
|
||||
let mut close = false;
|
||||
let is_repo = self.is_repo;
|
||||
let root = self
|
||||
.repo_root
|
||||
.as_ref()
|
||||
.map(|r| r.display().to_string())
|
||||
.unwrap_or_default();
|
||||
egui::Window::new("Git remote")
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.default_width(460.0)
|
||||
.show(ctx, |ui| {
|
||||
if !is_repo {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"This workspace is not a git repository yet. Use \u{201c}Init \
|
||||
git\u{201d} in the toolbar first, then set the remote here.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.separator();
|
||||
}
|
||||
ui.label(egui::RichText::new(format!("Repository: {root}")).small().weak());
|
||||
ui.add_space(4.0);
|
||||
ui.label("origin:");
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.remote_input)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text("ssh://git@host:port/user/repo.git"),
|
||||
);
|
||||
apply |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"The repository has to exist on the server already \u{2014} this \
|
||||
attaches to it, it does not create it. Until origin is set, \
|
||||
\u{201c}\u{27f3} Sync (git)\u{201d} commits locally and says so in \
|
||||
the log. Clear the box and press Set to detach.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Stored in the repository, not in this app's settings, so each \
|
||||
project keeps its own.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
apply |= ui.add_enabled(is_repo, egui::Button::new("Set origin")).clicked();
|
||||
close |= ui.button("Close").clicked();
|
||||
});
|
||||
});
|
||||
if apply {
|
||||
self.set_git_remote();
|
||||
}
|
||||
self.show_git_remote = open && !close;
|
||||
}
|
||||
|
||||
/// Carry out what a file row's right-click menu picked.
|
||||
///
|
||||
/// Right-clicking acts on the row you clicked, so that row becomes the
|
||||
|
||||
+102
-6
@@ -27,6 +27,17 @@ fn run_git(workspace: &Path, args: &[&str]) -> (bool, String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first real output line of a `run_git` result.
|
||||
///
|
||||
/// `run_git` prefixes a `$ git …` echo for the log window, so a caller after a
|
||||
/// single value — a path, a URL — has to step over it.
|
||||
fn first_output_line(out: &str) -> Option<&str> {
|
||||
out.lines()
|
||||
.find(|l| !l.starts_with("$ git") && !l.trim().is_empty())
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
}
|
||||
|
||||
/// True if the workspace directory is inside a git working tree.
|
||||
pub fn is_repo(workspace: &Path) -> bool {
|
||||
let (ok, out) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]);
|
||||
@@ -43,12 +54,7 @@ pub fn repo_root(workspace: &Path) -> Option<PathBuf> {
|
||||
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());
|
||||
let path = PathBuf::from(first_output_line(&out)?);
|
||||
(!path.as_os_str().is_empty()).then_some(path)
|
||||
}
|
||||
|
||||
@@ -58,6 +64,49 @@ fn has_origin(workspace: &Path) -> bool {
|
||||
ok && out.lines().any(|l| l.trim() == "origin")
|
||||
}
|
||||
|
||||
/// The URL `origin` points at, if the remote exists.
|
||||
///
|
||||
/// Read from the repository rather than from this app's settings: a remote
|
||||
/// belongs to the checkout, so the workspace is the source of truth and opening
|
||||
/// a different project shows that project's remote.
|
||||
pub fn origin_url(workspace: &Path) -> Option<String> {
|
||||
let (ok, out) = run_git(workspace, &["remote", "get-url", "origin"]);
|
||||
if !ok {
|
||||
return None;
|
||||
}
|
||||
first_output_line(&out).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Point `origin` at `url`, adding the remote when it is not there yet.
|
||||
///
|
||||
/// A blank `url` removes the remote instead, which is how a workspace is
|
||||
/// disconnected from a server without touching its history.
|
||||
pub fn set_origin(workspace: &Path, url: &str) -> GitOutcome {
|
||||
let Some(args) = origin_args(url, has_origin(workspace)) else {
|
||||
return GitOutcome {
|
||||
ok: true,
|
||||
log: "(there was no 'origin' to remove)\n".to_string(),
|
||||
};
|
||||
};
|
||||
let (ok, log) = run_git(workspace, &args);
|
||||
GitOutcome { ok, log }
|
||||
}
|
||||
|
||||
/// The git command that points `origin` at `url`, or `None` when there is
|
||||
/// nothing to do. Kept separate from running it, as with the cookiecutter
|
||||
/// argument builder, so the decision table can be asserted in tests without a
|
||||
/// repository on disk — picking `add` where `set-url` was needed (or the other
|
||||
/// way round) fails at the git level, not here.
|
||||
pub fn origin_args(url: &str, has_origin: bool) -> Option<Vec<&str>> {
|
||||
let url = url.trim();
|
||||
match (url.is_empty(), has_origin) {
|
||||
(true, true) => Some(vec!["remote", "remove", "origin"]),
|
||||
(true, false) => None,
|
||||
(false, true) => Some(vec!["remote", "set-url", "origin", url]),
|
||||
(false, false) => Some(vec!["remote", "add", "origin", url]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialise a new git repository in the workspace with a starter .gitignore.
|
||||
pub fn init(workspace: &Path) -> GitOutcome {
|
||||
let mut log = String::new();
|
||||
@@ -154,3 +203,50 @@ pub fn has_commits(workspace: &Path) -> bool {
|
||||
fn strip_command_echo(text: &str) -> String {
|
||||
text.split_once('\n').map_or(text, |(_, rest)| rest).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn setting_a_remote_adds_it_only_when_it_is_missing() {
|
||||
// Fresh repository: the remote has to be created.
|
||||
assert_eq!(
|
||||
origin_args("ssh://git@host:2228/me/book.git", false),
|
||||
Some(vec!["remote", "add", "origin", "ssh://git@host:2228/me/book.git"])
|
||||
);
|
||||
// Already pointed somewhere: `add` would fail, so repoint instead.
|
||||
assert_eq!(
|
||||
origin_args("ssh://git@host:2228/me/book.git", true),
|
||||
Some(vec!["remote", "set-url", "origin", "ssh://git@host:2228/me/book.git"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_the_box_detaches_the_remote() {
|
||||
assert_eq!(origin_args("", true), Some(vec!["remote", "remove", "origin"]));
|
||||
assert_eq!(origin_args(" ", true), Some(vec!["remote", "remove", "origin"]));
|
||||
// Nothing to remove, so nothing to run — and no error to report.
|
||||
assert_eq!(origin_args("", false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_url_is_trimmed_before_it_reaches_git() {
|
||||
// Copying a clone URL out of a web page brings whitespace with it, and
|
||||
// git would take it literally.
|
||||
assert_eq!(
|
||||
origin_args(" ssh://git@host/me/book.git\n", false),
|
||||
Some(vec!["remote", "add", "origin", "ssh://git@host/me/book.git"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_echo_is_skipped_when_reading_a_single_value() {
|
||||
// What `run_git` actually hands back: its own echo line, then output.
|
||||
let out = "$ git remote get-url origin\nssh://git@host/me/book.git\n";
|
||||
assert_eq!(first_output_line(out), Some("ssh://git@host/me/book.git"));
|
||||
// No remote configured: git says nothing, so neither do we.
|
||||
assert_eq!(first_output_line("$ git remote get-url origin\n"), None);
|
||||
assert_eq!(first_output_line(""), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user