Create the Gitea repository from the app, not a template hook

Repository creation was delegated entirely to a cookiecutter template's
post-gen hook. That only ever covered projects made through New project,
and when it went wrong it went wrong inside someone else's Python, where
all this app could report was that a hook exited non-zero.

The app now talks to Gitea itself. Settings > Git remote... gains a
"Create and attach" button: it POSTs to /api/v1/user/repos with the
configured server, user and token, then points origin at the clone URL
that comes back. A name that is already taken is fetched and attached to
rather than raised as an error, so retrying after a failure works.

The call runs on a worker thread with the existing channel-and-poll
pattern, so the UI does not block on the network. Errors are mapped by
status code, because a rejected token, a URL that is not a Gitea API and
a name the server refuses each need a different fix.

Uses ureq's send_string rather than send_json, whose feature this build
does not enable; no new dependency, and ldd still shows only
libc/libgcc/libm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
landon
2026-09-09 18:13:21 -05:00
parent 91718f7487
commit c4391155cd
7 changed files with 561 additions and 16 deletions
+42 -2
View File
@@ -116,10 +116,18 @@ pub struct Config {
/// Gitea account name, passed to hooks as `GITEA_USER`.
#[serde(default)]
pub gitea_user: String,
/// Gitea API token, passed to hooks as `GITEA_TOKEN`. Stored in plain text,
/// like the Mistral key.
/// Gitea API token, passed to hooks as `GITEA_TOKEN` and used for the app's
/// own API calls. Stored in plain text, like the Mistral key.
#[serde(default)]
pub gitea_token: String,
/// Whether repositories the app creates on Gitea are private.
#[serde(default)]
pub gitea_private: bool,
/// Whether to point `origin` at the SSH clone URL rather than the HTTPS one.
/// SSH is the default: it pushes on a key, where HTTPS would want a
/// credential typed in (or stored) on every push.
#[serde(default = "default_gitea_use_ssh")]
pub gitea_use_ssh: bool,
/// Folders kept out of the file panel entirely, matched leniently so
/// `Archive` covers `10-Archive`. An archive of dead drafts can hold
/// hundreds of files that would otherwise swamp the tree.
@@ -186,6 +194,11 @@ pub fn default_project_run_hooks() -> bool {
true
}
/// SSH by default — see [`Config::gitea_use_ssh`].
pub fn default_gitea_use_ssh() -> bool {
true
}
/// Folders excluded from the panel by default: the snowflake layout's archive,
/// which holds superseded drafts rather than working material.
pub fn default_hidden_folders() -> Vec<String> {
@@ -301,6 +314,8 @@ impl Default for Config {
gitea_url: String::new(),
gitea_user: String::new(),
gitea_token: String::new(),
gitea_private: false,
gitea_use_ssh: default_gitea_use_ssh(),
hidden_folders: default_hidden_folders(),
archive_folder: default_archive_folder(),
manuscript_title: String::new(),
@@ -418,6 +433,31 @@ mod tests {
// The new field is filled in from its default rather than left blank.
assert_eq!(cfg.new_file_template, default_new_file_template());
assert!(cfg.new_file_template.contains("{{marker}}"));
// Likewise the Gitea pair. `use_ssh` defaulting to *true* is the point:
// a `#[serde(default)]` bool would come back false and quietly attach
// every repository over HTTPS.
assert!(cfg.gitea_use_ssh);
assert!(!cfg.gitea_private);
}
/// A defaulted-to-`true` flag is only half done: the default has to lose to
/// what is actually in the file, or turning SSH off would never stick.
#[test]
fn choosing_https_survives_a_reload() {
let saved = r####"{
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt",
"show_preview": true,
"draft_marker": "### Rough Draft:",
"editor_zoom": 10.0,
"languagetool_host": "lt.example.com",
"mistral_api_key": "secret",
"gitea_use_ssh": false,
"gitea_private": true
}"####;
let cfg: Config = serde_json::from_str(saved).expect("config must parse");
assert!(!cfg.gitea_use_ssh, "an explicit false must beat the true default");
assert!(cfg.gitea_private);
}
#[test]