cleanUpTicket function

Future<void> cleanUpTicket({
  1. required Directory ticketDir,
  2. required List<Directory> repoDirs,
  3. required bool deleteRemoteBranch,
  4. required GgLog ggLog,
  5. required GgLog taskLog,
  6. ProcessRunner? processRunner,
})

Closes a finished ticket: deletes the remote feature branches, then moves the whole ticket folder into <root>/.trash/<ticket>.

Shared by do publish (when the user accepts the cleanup offer after every repo is published) and do rm ticket (the explicit way to close a ticket later). Nothing is deleted outright — the ticket moves to the trash as it is, in one piece: repositories on their feature branches with restored overrides and uncommitted leftovers, ticket.json, .gg/, the <ticket>.code-workspace file. Reopening — or even re-importing (gg do import ticket <path>) — the closed ticket from the trash therefore stays possible.

Only the remote branches need per-repo handling: each repository in repoDirs gets its remote feature branch (named after the ticket) deleted — unless deleteRemoteBranch is false. The deletions run before the move, while the repos' git folders are still at their original paths; when one fails, the ticket is kept in place so the command can simply be retried.

After the ticket folder is gone the caller's shell sits in a deleted directory, so the command to change to the workspace root is printed in blue.

Implementation

Future<void> cleanUpTicket({
  required Directory ticketDir,
  required List<Directory> repoDirs,
  required bool deleteRemoteBranch,
  required GgLog ggLog,
  required GgLog taskLog,
  ProcessRunner? processRunner,
}) async {
  final runner = processRunner ?? defaultProcessRunner;

  // Normalize before taking the basename. A caller inside the ticket folder
  // passes it as '.', and `basename('.')` is '.' — which then travelled all
  // the way into »git push origin --delete .«, failing with
  // »invalid refspec ':.'«. `normalize` on the absolute path resolves '.',
  // '..' and trailing separators to the folder's real name. Not
  // `canonicalize`: it lowercases on Windows, and a branch name is
  // case-sensitive.
  final ticketName = path.basename(path.normalize(ticketDir.absolute.path));

  // Step 1: Delete the remote branches — per repo, from the repos'
  // original locations. A failed deletion keeps the ticket where it is:
  // moving it anyway would strand the remaining branches, because the git
  // folders they are deleted from would be gone.
  var allBranchesHandled = true;
  for (final repoDir in repoDirs) {
    final repoName = path.basename(repoDir.path);

    if (!deleteRemoteBranch) {
      taskLog(cDetail('✓ Kept remote branch $ticketName for $repoName.'));
      continue;
    }

    if (!repoDir.existsSync()) {
      // Without the repo folder there is no git context to delete from —
      // e.g. the repo was removed by hand. Nothing to do here.
      taskLog(
        cDetail('✓ Repository $repoName is gone — no remote branch to delete.'),
      );
      continue;
    }

    try {
      await _deleteRemoteBranch(
        repoDir: repoDir,
        branchName: ticketName,
        ggLog: taskLog,
        processRunner: runner,
      );
    } catch (e) {
      allBranchesHandled = false;
      ggLog(
        cError('Failed to delete remote branch $ticketName for $repoName: $e'),
      );
    }
  }

  if (!allBranchesHandled) {
    ggLog(
      cWarn(
        'Ticket $ticketName was not moved to the trash because not every '
        'remote branch could be deleted. Fix the problem and retry, or '
        'use --no-delete-remote-branch.',
      ),
    );
    return;
  }

  // The workspace root the ticket belongs to — `<root>/<ticket>`, or the
  // legacy `<root>/tickets/<ticket>` — resolved before the move, while the
  // path still exists.
  final workspaceRoot = WorkspaceUtils.rootOfTicket(ticketDir.absolute);

  // Step 2: Move the whole folder in one go — everything the ticket holds
  // travels with it.
  try {
    final target = await Trash.moveTicketToTrash(ticketDir: ticketDir);
    // Where the ticket went — the user asked for the move, so this is a
    // detail, not a warning. It still goes to ggLog: a non-verbose run must
    // not swallow the one line that says where the work now lives. Normally
    // the ticket keeps its name inside the trash, so naming the trash folder
    // is enough; only a collision (an earlier ticket of the same name) makes
    // the full path worth printing.
    final movedTo = path.basename(target.path) == ticketName
        ? target.parent.path
        : target.path;
    ggLog(cDetail('Moved ticket $ticketName to $movedTo'));
  } catch (e) {
    ggLog(cError('Failed to move ticket $ticketName to the trash: $e'));
    return;
  }

  // The shell of the caller now sits inside a deleted folder — hand them
  // the way out.
  ggLog(cAction('\nChange to the workspace root with:'));
  ggLog(cCmd('  cd $workspaceRoot'));
}