Modified - [migrate] [cancel] [windows] [git] [retry] Made canceled repository migrations wait for the real clone shutdown before exposing retry/delete actions, and added Windows process-tree termination for migration clones.

- 1 - Fix: canceling a repository migration no longer marks the page as failed while the Git clone is still running.
- 2 - Mod: `Retry` and `Delete This Repository` stay hidden until the migration task is fully stopped.
- 3 - Fix: canceled migrations now persist as `stopped` and no longer show the raw `clone error: context canceled ...` message.
- 4 - Fix: added an opt-in Windows process-tree kill for migration clone commands via `taskkill /T /F`, because Git helper processes could remain alive after `Cancel`, keep writing `tmp_pack_*`, and block cleanup or retry.
This commit is contained in:
2026-05-24 04:11:48 +03:00
parent 70659dc6c3
commit 6ea9c8660f
14 changed files with 222 additions and 44 deletions
+9
View File
@@ -879,3 +879,12 @@ History search guidance:
182 - [2026-05-24 01:27:20] - v1.27.0-dev-194-gf293572182 - Type: Modified - [locale] [ro] Refined several Romanian UI strings for clearer wording and corrected grammar in settings, migration, repository, and admin badge messages.
- 1 - I updated `options/locale/locale_ro-RO.json` so several existing Romanian translations now use clearer and more natural phrasing, including the account deletion warning, Git migration progress text, archive action wording, workflow notification label order, and a few admin badge status messages with corrected agreement and grammar.
183 - [2026-05-24 01:44:32] - v1.27.0-dev-195-g70659dc6c3 - Type: Modified - [migrate] [cancel] [ui] Distinguished user-stopped repository migrations from genuine migration failures in the status UI.
- 1 - I updated `routers/web/repo/migrate.go`, `templates/repo/migrate/migrating.tmpl`, `web_src/js/features/repo-migrate.ts`, and the EN/RO locale files so a migration canceled by the user still uses the existing failed task state internally, but the repository migration page now renders it as `stopped` instead of `failed`, with dedicated stopped wording in both the main status title and the follow-up status message.
184 - [2026-05-24 03:12:34] - v1.27.0-dev-195-g70659dc6c3 - Type: Modified - [migrate] [cancel] [windows] [git] [retry] Made canceled repository migrations wait for the real clone shutdown before exposing retry/delete actions, and added Windows process-tree termination for migration clones.
- 1 - Fix: canceling a repository migration no longer marks the page as failed while the Git clone is still running.
- 2 - Mod: `Retry` and `Delete This Repository` stay hidden until the migration task is fully stopped.
- 3 - Fix: canceled migrations now persist as `stopped` and no longer show the raw `clone error: context canceled ...` message.
- 4 - Fix: added an opt-in Windows process-tree kill for migration clone commands via `taskkill /T /F`, because Git helper processes could remain alive after `Cancel`, keep writing `tmp_pack_*`, and block cleanup or retry.
+60 -4
View File
@@ -43,6 +43,7 @@ type Command struct {
cmdCancel process.CancelCauseFunc
cmdFinished process.FinishedFunc
cmdStartTime time.Time
cmdWaitDone chan struct{} // edit/add - by petru @ codex
parentPipeFiles []*os.File
parentPipeReaders []*os.File
@@ -207,8 +208,9 @@ func ToTrustedCmdArgs(args []string) TrustedCmdArgs {
}
type runOpts struct {
Env []string
Timeout time.Duration
Env []string
Timeout time.Duration
KillProcessTreeOnCancel bool // edit/add - by petru @ codex
// Dir is the working dir for the git command, however:
// FIXME: this could be incorrect in many cases, for example:
@@ -276,6 +278,11 @@ func (c *Command) WithTimeout(timeout time.Duration) *Command {
return c
}
func (c *Command) WithKillProcessTreeOnCancel(v bool) *Command { // edit/add - by petru @ codex
c.opts.KillProcessTreeOnCancel = v
return c
}
func (c *Command) makeStdoutStderr(w *io.Writer) (PipeReader, func()) {
pr, pw, err := os.Pipe()
if err != nil {
@@ -434,7 +441,13 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
c.cmdStartTime = time.Now()
c.cmd = exec.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...)
// start edit/add - by petru @ codex
if c.opts.KillProcessTreeOnCancel && useManualProcessTreeKillMode() {
c.cmd = exec.Command(c.prog, append(c.configArgs, c.args...)...)
} else {
c.cmd = exec.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...)
}
// end edit/add - by petru @ codex
if c.opts.Env == nil {
c.cmd.Env = os.Environ()
} else {
@@ -447,7 +460,18 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
c.cmd.Stdout = c.cmdStdout
c.cmd.Stdin = c.cmdStdin
c.cmd.Stderr = c.cmdStderr
return c.cmd.Start()
if err := c.cmd.Start(); err != nil {
return err
}
// start edit/add - by petru @ codex
c.cmdWaitDone = make(chan struct{})
if c.opts.KillProcessTreeOnCancel && c.cmd.Process != nil {
go c.killProcessTreeOnCancel()
}
// end edit/add - by petru @ codex
return nil
}
func (c *Command) closePipeFiles(files []*os.File) {
@@ -464,6 +488,13 @@ func (c *Command) discardPipeReaders(files []*os.File) {
func (c *Command) Wait() error {
defer func() {
// start edit/add - by petru @ codex
if c.cmdWaitDone != nil {
close(c.cmdWaitDone)
c.cmdWaitDone = nil
}
// end edit/add - by petru @ codex
// The reader in another goroutine might be still reading the stdout, so we shouldn't close the pipes here
// MakeStdoutPipe returns a closer function to force callers to close the pipe correctly
// Here we only need to mark the command as finished
@@ -515,6 +546,31 @@ func (c *Command) Wait() error {
return errors.Join(errCause, errWait)
}
// start edit/add - by petru @ codex
func (c *Command) killProcessTreeOnCancel() {
select {
case <-c.cmdWaitDone:
return
case <-c.cmdCtx.Done():
}
select {
case <-c.cmdWaitDone:
return
default:
}
if c.cmd == nil || c.cmd.Process == nil {
return
}
if err := killProcessTree(c.cmd.Process.Pid); err != nil {
log.Debug("failed to kill git process tree for pid %d: %v", c.cmd.Process.Pid, err)
}
}
// end edit/add - by petru @ codex
func (c *Command) StartWithStderr(ctx context.Context) RunStdError {
if c.cmdStderr != nil {
panic("caller-provided stderr receiver doesn't work with managed stderr buffer")
@@ -0,0 +1,17 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package gitcmd
// start edit/add - by petru @ codex
func useManualProcessTreeKillMode() bool {
return false
}
func killProcessTree(pid int) error {
return nil
}
// end edit/add - by petru @ codex
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build windows
package gitcmd
import (
"fmt"
"os/exec"
"strconv"
"strings"
)
// start edit/add - by petru @ codex
func useManualProcessTreeKillMode() bool {
return true
}
func killProcessTree(pid int) error {
if pid <= 0 {
return nil
}
output, err := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).CombinedOutput()
if err == nil {
return nil
}
msg := strings.ToLower(string(output))
if strings.Contains(msg, "there is no running instance") || strings.Contains(msg, "not found") {
return nil
}
return fmt.Errorf("taskkill /T /F /PID %d failed: %w: %s", pid, err, strings.TrimSpace(string(output)))
}
// end edit/add - by petru @ codex
+14 -12
View File
@@ -99,18 +99,19 @@ func (repo *Repository) IsEmpty() (bool, error) {
// CloneRepoOptions options when clone a repository
type CloneRepoOptions struct {
Timeout time.Duration
Mirror bool
Bare bool
Quiet bool
Branch string
Shared bool
NoCheckout bool
Depth int
Filter string
SkipTLSVerify bool
SingleBranch bool
Env []string
Timeout time.Duration
Mirror bool
Bare bool
Quiet bool
Branch string
Shared bool
NoCheckout bool
Depth int
Filter string
SkipTLSVerify bool
SingleBranch bool
KillProcessTreeOnCancel bool // edit/add - by petru @ codex
Env []string
}
// Clone clones original repository to target path.
@@ -170,6 +171,7 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
return cmd.
WithTimeout(opts.Timeout).
WithEnv(envs).
WithKillProcessTreeOnCancel(opts.KillProcessTreeOnCancel). // edit/add - by petru @ codex
RunWithStderr(ctx)
}
+4
View File
@@ -1287,6 +1287,10 @@
"repo.migrate.migrating_failed": "Migrating from <b>%s</b> failed.",
"repo.migrate.migrating_failed.error": "Failed to migrate: %s",
"repo.migrate.migrating_failed_no_addr": "Migration failed.",
"repo.migrate.migrating_canceling": "Stopping migration and terminating Git clone processes...",
"repo.migrate.migrating_stopped": "Migrating from <b>%s</b> stopped.",
"repo.migrate.migrating_stopped.error": "Migration stopped.",
"repo.migrate.migrating_stopped_no_addr": "Migration stopped.",
"repo.migrate.github.description": "Migrate data from github.com or other GitHub instances.",
"repo.migrate.git.description": "Migrate a repository only from any Git service.",
"repo.migrate.gitlab.description": "Migrate data from gitlab.com or other GitLab instances.",
+4
View File
@@ -1287,6 +1287,10 @@
"repo.migrate.migrating_failed": "Migrarea de la <b>%s</b> a eșuat.",
"repo.migrate.migrating_failed.error": "Migrare eșuată: %s",
"repo.migrate.migrating_failed_no_addr": "Migrarea a eșuat.",
"repo.migrate.migrating_canceling": "Se oprește migrarea și se închid procesele Git de clonare...",
"repo.migrate.migrating_stopped": "Migrarea de la <b>%s</b> a fost oprită.",
"repo.migrate.migrating_stopped.error": "Migrarea a fost oprită.",
"repo.migrate.migrating_stopped_no_addr": "Migrarea a fost oprită.",
"repo.migrate.github.description": "Migrează datele de pe github.com sau alte instanțe GitHub.",
"repo.migrate.git.description": "Migrează un repozitoriu de pe orice serviciu de tip Git.",
"repo.migrate.gitlab.description": "Migrează datele de pe gitlab.com sau alte instanțe GitLab.",
+18 -5
View File
@@ -281,13 +281,15 @@ func MigrateCancelPost(ctx *context.Context) {
ctx.Redirect(ctx.Repo.Repository.Link())
return
}
if migratingTask.Status == structs.TaskStatusRunning {
taskUpdate := &admin_model.Task{ID: migratingTask.ID, Status: structs.TaskStatusFailed, Message: "canceled"}
// start edit/add - by petru @ codex
if migratingTask.Status == structs.TaskStatusQueued || migratingTask.Status == structs.TaskStatusRunning {
taskUpdate := &admin_model.Task{ID: migratingTask.ID, Status: structs.TaskStatusStopped, Message: "canceling"}
if err = taskUpdate.UpdateCols(ctx, "status", "message"); err != nil {
ctx.ServerError("task.UpdateCols", err)
return
}
}
// end edit/add - by petru @ codex
ctx.Redirect(ctx.Repo.Repository.Link())
}
@@ -310,7 +312,15 @@ func MigrateStatus(ctx *context.Context) {
message := task.Message
if task.Message != "" && task.Message[0] == '{' {
// start edit/add - by petru @ codex
stopping := task.Status == structs.TaskStatusStopped && task.EndTime == 0
stopped := task.Status == structs.TaskStatusStopped && task.EndTime != 0
if stopping {
message = ctx.Locale.TrString("repo.migrate.migrating_canceling")
} else if stopped {
message = ctx.Locale.TrString("repo.migrate.migrating_stopped.error")
} else if task.Message != "" && task.Message[0] == '{' {
// assume message is actually a translatable string
var translatableMessage admin_model.TranslatableMessage
if err := json.Unmarshal([]byte(message), &translatableMessage); err != nil {
@@ -321,9 +331,12 @@ func MigrateStatus(ctx *context.Context) {
}
message = ctx.Locale.TrString(translatableMessage.Format, translatableMessage.Args...)
}
// end edit/add - by petru @ codex
ctx.JSON(http.StatusOK, map[string]any{
"status": task.Status,
"message": message,
"status": task.Status,
"message": message,
"stopping": stopping, // edit/add - by petru @ codex
"stopped": stopped, // edit/add - by petru @ codex
})
}
+2 -1
View File
@@ -198,7 +198,8 @@ func checkHomeCodeViewable(ctx *context.Context) {
ctx.Data["Repo"] = ctx.Repo
ctx.Data["MigrateTask"] = task
ctx.Data["CloneAddr"], _ = util.SanitizeURL(cfg.CloneAddr)
ctx.Data["Failed"] = task.Status == structs.TaskStatusFailed
ctx.Data["Stopping"] = task.Status == structs.TaskStatusStopped && task.EndTime == 0 // edit/add - by petru @ codex
ctx.Data["Failed"] = task.Status == structs.TaskStatusFailed || (task.Status == structs.TaskStatusStopped && task.EndTime != 0) // edit/add - by petru @ codex
ctx.HTML(http.StatusOK, tplMigrating)
return
}
+10 -8
View File
@@ -45,10 +45,11 @@ func cloneWiki(ctx context.Context, repo *repo_model.Repository, opts migration.
}
}
if err := gitrepo.CloneExternalRepo(ctx, wikiRemoteURL, storageRepo, git.CloneRepoOptions{
Mirror: true,
Quiet: true,
Timeout: migrateTimeout,
SkipTLSVerify: setting.Migrations.SkipTLSVerify,
Mirror: true,
Quiet: true,
Timeout: migrateTimeout,
KillProcessTreeOnCancel: true, // edit/add - by petru @ codex
SkipTLSVerify: setting.Migrations.SkipTLSVerify,
}); err != nil {
log.Error("Clone wiki failed, err: %v", err)
cleanIncompleteWikiPath()
@@ -91,10 +92,11 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
}
if err := gitrepo.CloneExternalRepo(ctx, opts.CloneAddr, repo, git.CloneRepoOptions{
Mirror: true,
Quiet: true,
Timeout: migrateTimeout,
SkipTLSVerify: setting.Migrations.SkipTLSVerify,
Mirror: true,
Quiet: true,
Timeout: migrateTimeout,
KillProcessTreeOnCancel: true, // edit/add - by petru @ codex
SkipTLSVerify: setting.Migrations.SkipTLSVerify,
}); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return repo, fmt.Errorf("clone timed out, consider increasing [git.timeout] MIGRATE in app.ini, underlying err: %w", err)
+21 -2
View File
@@ -41,6 +41,18 @@ func handleCreateError(owner *user_model.User, err error) error {
}
}
// start edit/add - by petru @ codex
func isMigrationCancelRequested(ctx context.Context, t *admin_model.Task) bool {
if t.Status == structs.TaskStatusStopped && t.Message == "canceling" {
return true
}
currentTask, loadErr := admin_model.GetMigratingTask(ctx, t.RepoID)
return loadErr == nil && currentTask.ID == t.ID && currentTask.Status == structs.TaskStatusStopped && currentTask.Message == "canceling"
}
// end edit/add - by petru @ codex
func runMigrateTask(ctx context.Context, t *admin_model.Task) (err error) {
defer func(ctx context.Context) {
if e := recover(); e != nil {
@@ -59,9 +71,16 @@ func runMigrateTask(ctx context.Context, t *admin_model.Task) (err error) {
log.Error("runMigrateTask[%d] by DoerID[%d] to RepoID[%d] for OwnerID[%d] failed: %v", t.ID, t.DoerID, t.RepoID, t.OwnerID, err)
// start edit/add - by petru @ codex
t.EndTime = timeutil.TimeStampNow()
t.Status = structs.TaskStatusFailed
t.Message = err.Error()
if isMigrationCancelRequested(ctx, t) {
t.Status = structs.TaskStatusStopped
t.Message = "canceled"
} else {
t.Status = structs.TaskStatusFailed
t.Message = err.Error()
}
// end edit/add - by petru @ codex
if err := t.UpdateCols(ctx, "status", "message", "end_time"); err != nil {
log.Error("Task UpdateCols failed: %v", err)
}
+4 -1
View File
@@ -136,9 +136,12 @@ func RetryMigrateTask(ctx context.Context, repoID int64) error {
log.Error("GetMigratingTask: %v", err)
return err
}
if migratingTask.Status == structs.TaskStatusQueued || migratingTask.Status == structs.TaskStatusRunning {
// start edit/add - by petru @ codex
if migratingTask.Status == structs.TaskStatusQueued || migratingTask.Status == structs.TaskStatusRunning ||
(migratingTask.Status == structs.TaskStatusStopped && migratingTask.EndTime == 0) {
return nil
}
// end edit/add - by petru @ codex
// TODO Need to removing the storage/database garbage brought by the failed task
+5 -8
View File
@@ -26,21 +26,18 @@
</div>
<div id="repo_migrating_failed" class="tw-hidden">
{{if .CloneAddr}}
<p>{{ctx.Locale.Tr "repo.migrate.migrating_failed" .CloneAddr}}</p>
<p id="repo_migrating_failed_title" data-failed-html="{{ctx.Locale.Tr "repo.migrate.migrating_failed" .CloneAddr}}" data-stopped-html="{{ctx.Locale.Tr "repo.migrate.migrating_stopped" .CloneAddr}}">{{ctx.Locale.Tr "repo.migrate.migrating_failed" .CloneAddr}}</p> <!-- edit/add - by petru @ codex -->
{{else}}
<p>{{ctx.Locale.Tr "repo.migrate.migrating_failed_no_addr"}}</p>
<p id="repo_migrating_failed_title" data-failed-html="{{ctx.Locale.Tr "repo.migrate.migrating_failed_no_addr"}}" data-stopped-html="{{ctx.Locale.Tr "repo.migrate.migrating_stopped_no_addr"}}">{{ctx.Locale.Tr "repo.migrate.migrating_failed_no_addr"}}</p> <!-- edit/add - by petru @ codex -->
{{end}}
<p id="repo_migrating_failed_error"></p>
</div>
{{if .Permission.IsAdmin}}
<div class="divider"></div>
<div class="item">
{{if .Failed}}
<button class="ui basic red show-modal button" data-modal="#delete-repo-modal">{{ctx.Locale.Tr "repo.settings.delete"}}</button>
{{else}}
<button class="ui basic show-modal button" data-modal="#cancel-repo-modal">{{ctx.Locale.Tr "cancel"}}</button>
{{end}}
<button id="repo_migrating_retry" data-migrating-task-retry-url="{{.Link}}/settings/migrate/retry" class="ui basic button tw-hidden">{{ctx.Locale.Tr "retry"}}</button>
<button id="repo_migrating_delete" class="ui basic red show-modal button{{if not .Failed}} tw-hidden{{end}}" data-modal="#delete-repo-modal">{{ctx.Locale.Tr "repo.settings.delete"}}</button> <!-- edit/add - by petru @ codex -->
<button id="repo_migrating_cancel" class="ui basic show-modal button{{if or .Failed .Stopping}} tw-hidden{{end}}" data-modal="#cancel-repo-modal">{{ctx.Locale.Tr "cancel"}}</button> <!-- edit/add - by petru @ codex -->
<button id="repo_migrating_retry" data-migrating-task-retry-url="{{.Link}}/settings/migrate/retry" class="ui basic button{{if not .Failed}} tw-hidden{{end}}">{{ctx.Locale.Tr "retry"}}</button> <!-- edit/add - by petru @ codex -->
</div>
{{end}}
</div>
+16 -3
View File
@@ -5,7 +5,8 @@ export function initRepoMigrationStatusChecker() {
const repoMigrating = document.querySelector('#repo_migrating');
if (!repoMigrating) return;
document.querySelector<HTMLButtonElement>('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry);
const repoMigratingRetry = document.querySelector<HTMLButtonElement>('#repo_migrating_retry');
if (repoMigratingRetry) repoMigratingRetry.addEventListener('click', doMigrationRetry); // edit/add - by petru @ codex
const repoLink = repoMigrating.getAttribute('data-migrating-repo-link');
@@ -21,19 +22,31 @@ export function initRepoMigrationStatusChecker() {
document.querySelector('#repo_migrating_progress_message')!.textContent = data.message;
}
// migration cancel requested, keep polling until the clone process tree is actually gone
if (data.stopping) {
hideElem('#repo_migrating_cancel'); // edit/add - by petru @ codex
hideElem('#repo_migrating_retry'); // edit/add - by petru @ codex
hideElem('#repo_migrating_delete'); // edit/add - by petru @ codex
return true;
}
// TaskStatusFinished
if (data.status === 4) {
window.location.reload();
return false;
}
// TaskStatusFailed
if (data.status === 3) {
// TaskStatusFailed / TaskStatusStopped
if (data.status === 3 || data.stopped) {
hideElem('#repo_migrating_progress');
hideElem('#repo_migrating');
hideElem('#repo_migrating_cancel'); // edit/add - by petru @ codex
showElem('#repo_migrating_retry');
showElem('#repo_migrating_delete'); // edit/add - by petru @ codex
showElem('#repo_migrating_failed');
showElem('#repo_migrating_failed_image');
const failedTitle = document.querySelector<HTMLElement>('#repo_migrating_failed_title'); // edit/add - by petru @ codex
if (failedTitle) failedTitle.innerHTML = failedTitle.getAttribute(data.stopped ? 'data-stopped-html' : 'data-failed-html') || failedTitle.innerHTML; // edit/add - by petru @ codex
document.querySelector('#repo_migrating_failed_error')!.textContent = data.message;
return false;
}