tbdflow 0.32.1

A CLI to streamline your Git workflow for Trunk-Based Development.
Documentation
# ═══════════════════════════════════════════════════════════════════════════════
# tbdflow — Atomic Undo (The Panic Button)
# ═══════════════════════════════════════════════════════════════════════════════
# User Story: As a developer, I want to revert a commit on the trunk by its SHA
# so that I can quickly restore the trunk to a green state when something breaks.
# ═══════════════════════════════════════════════════════════════════════════════

feature "tbdflow Atomic Undo"

actors: Terminal

settings {
    timeout_seconds = 30
    stop_on_failure = true
    shell_path = "/bin/bash"
}

var UNDO_REPO = "/tmp/tbdflow_undo_test"
var UNDO_BARE = "/tmp/tbdflow_undo_bare.git"
var NOPUSH_REPO = "/tmp/tbdflow_undo_nopush"
var NOPUSH_BARE = "/tmp/tbdflow_undo_nopush_bare.git"
var INVALID_REPO = "/tmp/tbdflow_undo_invalid"
var INVALID_BARE = "/tmp/tbdflow_undo_invalid_bare.git"

# ═══════════════════════════════════════════════════════════════════════════════
# IMPLEMENTATION LAYER — Tasks hide the "how"
# ═══════════════════════════════════════════════════════════════════════════════

# --- Setup drivers ---

task init_repo(repo, bare) {
    Terminal run "rm -rf ${repo} ${bare} && mkdir -p ${repo}"
    Terminal run "git init --bare ${bare}"
    Terminal run "git -C ${repo} init && git -C ${repo} commit --allow-empty -m 'init'"
    Terminal run "git -C ${repo} remote add origin ${bare}"
    Terminal run "git -C ${repo} push -u origin main"
    Terminal set_cwd "${repo}"
}

task cleanup_repo(repo, bare) {
    Terminal run "rm -rf ${repo} ${bare}"
}

# --- Action drivers ---
# NOTE: SHA discovery uses `tbdflow sync` (the user-facing command) to
# identify commits. The `head -1 | awk` pipeline extracts the short SHA
# from the sync output — this mirrors how a developer reads the SHA from
# the "Recent activity" section and copies it.

task make_change(filename, content) {
    Terminal run "echo ${content} > ${filename}"
}

task commit(type, message) {
    Terminal run "tbdflow commit -t ${type} -m '${message}'"
}

task sync() {
    Terminal run "tbdflow sync"
}

task undo_latest_commit() {
    # Uses `tbdflow head-sha` to get the SHA, same as a developer
    # running `tbdflow sync` to see the history then copying the hash.
    Terminal run "tbdflow undo $(tbdflow head-sha)"
}

task undo_latest_commit_no_push() {
    Terminal run "tbdflow undo $(tbdflow head-sha) --no-push"
}

task undo_latest_commit_dry_run() {
    Terminal run "tbdflow --dry-run undo $(tbdflow head-sha)"
}

task undo(sha) {
    Terminal run "tbdflow undo ${sha}"
}

task check_file_exists(filename) {
    Terminal run "test -f ${filename} && echo 'FILE_EXISTS' || echo 'FILE_GONE'"
}

# --- Condition drivers ---

task verify_undo_succeeded() {
    Terminal last_command succeeded
    Terminal output_contains "has been reverted"
    Terminal output_not_contains "Error"
}

task verify_undo_local_only() {
    Terminal last_command succeeded
    Terminal output_contains "Revert commit created locally"
    Terminal output_contains "--no-push"
}

task verify_undo_rejected() {
    Terminal last_command failed
    Terminal output_not_contains "has been reverted"
}

task verify_file_removed() {
    Terminal last_command succeeded
    Terminal output_contains "FILE_GONE"
}

task verify_revert_in_history() {
    Terminal last_command succeeded
    Terminal output_contains "Revert"
}

task verify_dry_run_no_side_effects() {
    Terminal last_command succeeded
    Terminal output_contains "DRY RUN"
    Terminal output_contains "git revert"
}

# ═══════════════════════════════════════════════════════════════════════════════
# BUSINESS SPECIFICATION LAYER — Scenarios describe the "what"
# ═══════════════════════════════════════════════════════════════════════════════

# ─────────────────────────────────────────────────────────────────────────────
# AC1: `tbdflow undo <sha>` must revert the commit and push the revert.
# AC2: The reverted file changes must be removed from the working tree.
# AC3: The revert commit must appear in the history.
# AC4: `--no-push` must create the revert locally without pushing.
# AC5: A non-existent SHA must be rejected gracefully.
# AC6: `--dry-run` must preview the undo without making changes.
# ─────────────────────────────────────────────────────────────────────────────

scenario "Reverting a commit on the trunk" {

    test Setup "Initialise test repository" {
        given:
            Test can_start
        when:
            init_repo("${UNDO_REPO}", "${UNDO_BARE}")
        then:
            Terminal last_command succeeded
    }

    test CommitBadChange "Push a commit that breaks the trunk" {
        given:
            Test has_succeeded Setup
        when:
            make_change("BAD_CHANGE.txt", "this breaks the build")
            commit("feat", "add bad change")
        then:
            Terminal last_command succeeded
            Terminal output_contains "Successfully committed"
    }

    test IdentifyAndUndo "Use sync to find the bad commit, then undo it" {
        given:
            Test has_succeeded CommitBadChange
        when:
            undo_latest_commit()
        then:
            verify_undo_succeeded()
    }

    test FileRemoved "The bad file is no longer in the working tree" {
        given:
            Test has_succeeded IdentifyAndUndo
        when:
            check_file_exists("BAD_CHANGE.txt")
        then:
            verify_file_removed()
    }

    test RevertInHistory "The revert commit appears in the log" {
        given:
            Test has_succeeded FileRemoved
        when:
            sync()
        then:
            verify_revert_in_history()
    }

    after {
        cleanup_repo("${UNDO_REPO}", "${UNDO_BARE}")
    }
}

scenario "Reverting locally without pushing" {

    test Setup "Initialise test repository" {
        given:
            Test can_start
        when:
            init_repo("${NOPUSH_REPO}", "${NOPUSH_BARE}")
        then:
            Terminal last_command succeeded
    }

    test CommitChange "Push a commit to main" {
        given:
            Test has_succeeded Setup
        when:
            make_change("OOPS.txt", "oops")
            commit("feat", "add oops")
        then:
            Terminal last_command succeeded
    }

    test UndoLocalOnly "Use sync to find the commit, revert with --no-push" {
        given:
            Test has_succeeded CommitChange
        when:
            undo_latest_commit_no_push()
        then:
            verify_undo_local_only()
    }

    test FileRemovedLocally "The file is gone from the working tree" {
        given:
            Test has_succeeded UndoLocalOnly
        when:
            check_file_exists("OOPS.txt")
        then:
            verify_file_removed()
    }

    after {
        cleanup_repo("${NOPUSH_REPO}", "${NOPUSH_BARE}")
    }
}

scenario "Rejecting invalid undo targets" {

    test Setup "Initialise test repository" {
        given:
            Test can_start
        when:
            init_repo("${INVALID_REPO}", "${INVALID_BARE}")
        then:
            Terminal last_command succeeded
    }

    test NonExistentSha "Reject a SHA that does not exist" {
        given:
            Test has_succeeded Setup
        when:
            undo("deadbeefdeadbeef")
        then:
            verify_undo_rejected()
    }

    test DryRunPreview "Dry-run previews the undo without side effects" {
        given:
            Test has_succeeded NonExistentSha
        when:
            make_change("safe.txt", "safe")
            commit("feat", "add safe file")
            undo_latest_commit_dry_run()
        then:
            verify_dry_run_no_side_effects()
    }

    test FileStillExistsAfterDryRun "The file remains after a dry-run undo" {
        given:
            Test has_succeeded DryRunPreview
        when:
            check_file_exists("safe.txt")
        then:
            Terminal last_command succeeded
            Terminal output_contains "FILE_EXISTS"
    }

    after {
        cleanup_repo("${INVALID_REPO}", "${INVALID_BARE}")
    }
}